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

React实现PDF加载编辑后保存至NestJS后端的问题求助

解决React前端修改PDF表单后无法保存到NestJS后端的问题

核心问题分析

当前代码存在两个关键问题导致保存失败:

  • 用<embed>标签预览PDF时,用户在原生PDF查看器中的修改无法同步到pdf-lib的PDFDocument实例,最终保存的还是原始PDF内容
  • 保存时将二进制PDF数据转成JSON数组传输,不仅效率低下,后端也难以正确解析还原

前端代码修复方案

我们将替换预览交互逻辑,在React中渲染与PDF表单绑定的可编辑组件,实时同步用户修改到PDFDocument实例,同时优化请求方式用FormData传输二进制数据:

import React, { useRef, useState } from "react"
import { PDFDocument } from "pdf-lib"

const PDFViewer = () => {
  const domain = process.env.REACT_APP_API_URL
  const pdfUrl = `${domain}/example.pdf`
  const [pdfView, setPdfView] = useState(null)
  const pdfDocRef = useRef(null)
  // 存储PDF表单域对应的值,绑定到React表单
  const [formValues, setFormValues] = useState({})

  // 加载PDF并初始化表单数据
  const loadPDF = async () => {
    try {
      const response = await fetch(pdfUrl)
      if (!response.ok) throw new Error("Failed to fetch PDF")

      const pdfBytes = await response.arrayBuffer()
      const pdfDoc = await PDFDocument.load(new Uint8Array(pdfBytes))
      pdfDocRef.current = pdfDoc

      // 提取PDF表单域初始值
      const form = pdfDoc.getForm()
      const fields = form.getFields()
      const initialValues = {}
      
      fields.forEach(field => {
        const fieldName = field.getName()
        if (field.constructor.name === "PDFTextField") {
          initialValues[fieldName] = field.getText()
        } else if (field.constructor.name === "PDFDropdown") {
          initialValues[fieldName] = field.getSelected()
        } else if (field.constructor.name === "PDFRadioGroup") {
          initialValues[fieldName] = field.getSelected()
        }
      })
      setFormValues(initialValues)

      // 生成预览用的Blob URL
      const modifiedBytes = await pdfDoc.save()
      const blob = new Blob([modifiedBytes], { type: "application/pdf" })
      setPdfView(URL.createObjectURL(blob))
    } catch (error) {
      console.error("Error loading PDF:", error)
    }
  }

  // 将React表单值同步到PDFDocument实例
  const syncFormToPDF = () => {
    if (!pdfDocRef.current) return
    const form = pdfDocRef.current.getForm()
    
    Object.entries(formValues).forEach(([fieldName, value]) => {
      const field = form.getField(fieldName)
      if (!field) return

      if (field.constructor.name === "PDFTextField") {
        field.setText(value)
      } else if (field.constructor.name === "PDFDropdown") {
        field.select(value)
      } else if (field.constructor.name === "PDFRadioGroup") {
        field.select(value)
      }
    })
  }

  // 保存修改后的PDF到服务器
  const savePDF = async () => {
    if (!pdfDocRef.current) return

    // 先同步表单值到PDF实例
    syncFormToPDF()
    const modifiedBytes = await pdfDocRef.current.save()
    const pdfBlob = new Blob([modifiedBytes], { type: "application/pdf" })

    try {
      const formData = new FormData()
      formData.append("pdfFile", pdfBlob, "example.pdf")

      const response = await fetch(pdfUrl, {
        method: "PUT",
        body: formData,
      })

      if (response.ok) {
        console.log("PDF saved successfully")
        // 更新预览内容
        setPdfView(URL.createObjectURL(pdfBlob))
      } else {
        console.error("Failed to save PDF")
      }
    } catch (error) {
      console.error("Error saving PDF:", error)
    }
  }

  // 渲染与PDF绑定的表单组件(需替换为实际PDF表单域名称)
  const renderFormFields = () => {
    return (
      <div style={{ margin: "15px 0" }}>
        {/* 示例输入框 */}
        <div style={{ marginBottom: "10px" }}>
          <label>姓名:</label>
          <input
            type="text"
            value={formValues["username"] || ""}
            onChange={(e) => setFormValues(prev => ({...prev, username: e.target.value}))}
            style={{ marginLeft: "8px" }}
          />
        </div>
        {/* 示例下拉框 */}
        <div style={{ marginBottom: "10px" }}>
          <label>部门:</label>
          <select
            value={formValues["department"] || ""}
            onChange={(e) => setFormValues(prev => ({...prev, department: e.target.value}))}
            style={{ marginLeft: "8px" }}
          >
            <option value="技术部">技术部</option>
            <option value="市场部">市场部</option>
          </select>
        </div>
        {/* 示例单选组 */}
        <div>
          <label>性别:</label>
          <input
            type="radio"
            name="gender"
            value="男"
            checked={formValues["gender"] === "男"}
            onChange={(e) => setFormValues(prev => ({...prev, gender: e.target.value}))}
            style={{ marginLeft: "8px" }}
          />
          <span>男</span>
          <input
            type="radio"
            name="gender"
            value="女"
            checked={formValues["gender"] === "女"}
            onChange={(e) => setFormValues(prev => ({...prev, gender: e.target.value}))}
            style={{ marginLeft: "8px" }}
          />
          <span>女</span>
        </div>
        {/* 其余表单域按此格式添加 */}
      </div>
    )
  }

  return (
    <div>
      <button onClick={loadPDF}>Load PDF</button>
      <button onClick={savePDF} style={{ marginLeft: "10px" }}>Save PDF</button>
      
      {/* 渲染可编辑表单,用户在此输入修改 */}
      {Object.keys(formValues).length > 0 && renderFormFields()}

      {/* PDF预览区域 */}
      <div style={{ marginTop: "20px" }}>
        {pdfView && (
          <embed
            title="PDF Viewer"
            src={pdfView}
            width="100%"
            height="500px"
            style={{ border: "none" }}
          />
        )}
      </div>
    </div>
  )
}

export default PDFViewer

NestJS后端接口实现

方案1:保存到服务器文件系统

import { Controller, Put, UploadedFile, UseInterceptors, Res } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { Response } from 'express';

@Controller()
export class PdfController {
  @Put('example.pdf')
  @UseInterceptors(FileInterceptor('pdfFile', {
    storage: diskStorage({
      destination: './uploads', // 提前创建此目录并配置读写权限
      filename: (req, file, cb) => {
        cb(null, `example${extname(file.originalname)}`); // 覆盖原文件
      },
    }),
    limits: { fileSize: 5 * 1024 * 1024 }, // 限制文件大小为5MB
  }))
  async updatePdf(@UploadedFile() file: Express.Multer.File, @Res() res: Response) {
    if (!file) {
      return res.status(400).send('No file uploaded');
    }
    return res.status(200).send('PDF updated successfully');
  }
}

方案2:保存到PostgreSQL数据库

先创建存储PDF的表:

CREATE TABLE pdf_files (
  id SERIAL PRIMARY KEY,
  filename VARCHAR(255) NOT NULL UNIQUE,
  data BYTEA NOT NULL,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

编写服务层代码:

// pdf.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PdfFile } from './pdf-file.entity';

@Injectable()
export class PdfService {
  constructor(
    @InjectRepository(PdfFile)
    private pdfRepository: Repository<PdfFile>,
  ) {}

  async updatePdf(filename: string, data: Buffer) {
    let pdf = await this.pdfRepository.findOne({ where: { filename } });
    if (pdf) {
      pdf.data = data;
      pdf.updated_at = new Date();
    } else {
      pdf = this.pdfRepository.create({ filename, data });
    }
    return this.pdfRepository.save(pdf);
  }

  async getPdf(filename: string) {
    return this.pdfRepository.findOne({ where: { filename } });
  }
}

编写控制器代码:

// pdf.controller.ts
import { Controller, Put, UploadedFile, UseInterceptors, Get, Res } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { PdfService } from './pdf.service';
import { Response } from 'express';

@Controller()
export class PdfController {
  constructor(private readonly pdfService: PdfService) {}

  @Put('example.pdf')
  @UseInterceptors(FileInterceptor('pdfFile'))
  async updatePdf(@UploadedFile() file: Express.Multer.File) {
    await this.pdfService.updatePdf('example.pdf', file.buffer);
    return { message: 'PDF updated successfully' };
  }

  @Get('example.pdf')
  async getPdf(@Res() res: Response) {
    const pdf = await this.pdfService.getPdf('example.pdf');
    if (!pdf) {
      return res.status(404).send('PDF not found');
    }
    res.setHeader('Content-Type', 'application/pdf');
    res.send(pdf.data);
  }
}

关键注意事项

  • React表单的名称必须与PDF中的表单域名称完全一致,否则无法同步修改
  • 服务器端的uploads目录需提前创建,并确保进程有读写权限
  • 生产环境需添加文件格式验证、大小限制等安全措施
  • 若用PostgreSQL存储二进制数据,注意BYTEA类型默认最大存储1GB

内容的提问来源于stack exchange,提问作者Sudhakar J

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.15 07:27:07