打印本页

在 React 中使用 JavaScript 为 PDF 文档创建动作

PDF 文档默认只是一幅静态画面:读者能翻能看,文档本身却不会对任何操作作出反应。要让读者点一下图片就播放一段声音,或者点一下某块区域就跳到指定页、翻开内嵌的附件,就得给文档挂上「动作」(action)。这类交互过去要靠专门的排版软件逐项设置,或干脆放到服务端生成——前者门槛不低,后者意味着文件要离开本地。

Spire.PDF for JavaScript 基于 WebAssembly 在浏览器端加载、修改与保存 PDF 文档,动作全部在本地生成,通过虚拟文件系统(VFS)读写,无需后端配合。本文用 PdfActionAnnotation 承载四种动作:PdfGoToAction 跳到文档内的页、PdfSoundAction 在点击图片时播放音频、PdfEmbeddedGoToAction 打开内嵌的附件、PdfLaunchAction 打开文档外部的文件。

本文介绍四个核心功能点:

有关安装和项目配置,请参考 React 项目中集成 Spire.PDF for JavaScript。以下示例默认已安装 Spire.PDF 并完成 WebAssembly 模块初始化。


创建跳转动作

跳转动作把阅读位置从当前页带到文档里的另一处。

function App() {
  const addGoToAction = async () => {
    // 获取 Spire.PDF WASM 模块
    const pdfModule = window.wasmModule?.spirepdf;

    // 检查模块是否就绪
    if (!pdfModule) {
      alert('Spire.PDF is not ready yet');
      return;
    }

    // 将含中文字形的字体载入 VFS 的字体目录
    await window.spire.FetchFileToVFS('SIMSUN.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);

    // 将待处理的 PDF 载入 VFS
    const inputFileName = '示例文档.pdf';
    await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}/data/`);

    // 创建 PdfDocument 对象并加载文档
    let doc = new pdfModule.PdfDocument();
    doc.LoadFromFile(inputFileName);

    // 第一页放跳转入口,第二页当落点
    let firstPage = doc.Pages.get_Item(0);
    let secondPage = doc.Pages.get_Item(1);

    // 用目标页构造落点,页面顶部、缩放 100%
    let destination = new pdfModule.PdfDestination({ page: secondPage });
    destination.Mode = pdfModule.PdfDestinationMode.Location;
    destination.Location = new pdfModule.PointF(0, 0);
    destination.Zoom = 1;

    // 把落点包成跳转动作
    let action = new pdfModule.PdfGoToAction({ destination: destination });

    // 在第一页画一个按钮,矩形即点击热区
    let bounds = new pdfModule.RectangleF({ x: 72, y: 720, width: 200, height: 28 });
    let font = new pdfModule.PdfTrueTypeFont({ fontFile: '/Library/Fonts/SIMSUN.TTF', size: 12 });
    let format = new pdfModule.PdfStringFormat({ alignment: pdfModule.PdfTextAlignment.Center, lineAlignment: pdfModule.PdfVerticalAlignment.Middle });
    firstPage.Canvas.DrawRectangle({ brush: pdfModule.PdfBrushes.get_LightGray(), rectangle: bounds });
    firstPage.Canvas.DrawString({ s: '跳转到第 2 页', font: font, brush: pdfModule.PdfBrushes.get_Black(), layoutRectangle: bounds, format: format });

    // 动作注解覆盖按钮区域,点击即触发跳转
    let annotation = new pdfModule.PdfActionAnnotation(bounds, action);
    firstPage.Annotations.Add(annotation);

    // 保存文档
    const outputFileName = '跳转动作.pdf';
    doc.SaveToFile(outputFileName);
    doc.Close();

    // 从 VFS 读取生成的文件,触发下载
    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], { type: 'application/pdf' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>创建跳转动作</h1>
      <button onClick={addGoToAction}>
        开始创建
      </button>
    </div>
  );
}

export default App;

第一页底部出现一个「跳转到第 2 页」按钮,点击后阅读位置跳到第二页顶部:

第一页底部出现一个「跳转到第 2 页」按钮,点击后阅读位置跳到第二页顶部


创建声音动作

声音动作把音频随文档一起分发,点击页面上的图片即可播放。

function App() {
  const addSoundAction = async () => {
    // 获取 Spire.PDF WASM 模块
    const pdfModule = window.wasmModule?.spirepdf;

    // 检查模块是否就绪
    if (!pdfModule) {
      alert('Spire.PDF is not ready yet');
      return;
    }

    // 将待处理的 PDF、提示音与图标载入 VFS
    const inputFileName = '示例文档.pdf';
    await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}/data/`);
    const soundFileName = '提示音.wav';
    await window.spire.FetchFileToVFS(soundFileName, "", `${process.env.PUBLIC_URL}/data/`);
    const iconFileName = '声音图标.png';
    await window.spire.FetchFileToVFS(iconFileName, "", `${process.env.PUBLIC_URL}/data/`);

    // 创建 PdfDocument 对象并加载文档
    let doc = new pdfModule.PdfDocument();
    doc.LoadFromFile(inputFileName);

    // 获取第一页
    let firstPage = doc.Pages.get_Item(0);

    // 用音频文件构造声音动作
    let soundAction = new pdfModule.PdfSoundAction(soundFileName);

    // 采样格式与音频一致:16 位、单声道、有符号 PCM、22050 Hz
    soundAction.Sound.Bits = 16;
    soundAction.Sound.Channels = pdfModule.PdfSoundChannels.Mono;
    soundAction.Sound.Encoding = pdfModule.PdfSoundEncoding.Signed;
    soundAction.Sound.Rate = 22050;

    // 播放参数:音量 0.5、循环、与其它声音混音、非同步
    soundAction.Volume = 0.5;
    soundAction.Repeat = true;
    soundAction.Mix = true;
    soundAction.Synchronous = false;

    // 把图标画进页面上的矩形,矩形即点击热区
    let icon = pdfModule.PdfImage.FromFile(iconFileName);
    let bounds = new pdfModule.RectangleF({ x: 72, y: 600, width: 160, height: 160 });
    firstPage.Canvas.DrawImage({ image: icon, rectangle: bounds });

    // 动作注解覆盖图标,点击图片即播放音频
    let annotation = new pdfModule.PdfActionAnnotation(bounds, soundAction);
    firstPage.Annotations.Add(annotation);

    // 保存文档
    const outputFileName = '声音动作.pdf';
    doc.SaveToFile(outputFileName);
    doc.Close();

    // 从 VFS 读取生成的文件,触发下载
    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], { type: 'application/pdf' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>创建声音动作</h1>
      <button onClick={addSoundAction}>
        开始创建
      </button>
    </div>
  );
}

export default App;

第一页出现一张扬声器图标,点击它时会播放内嵌音频。若希望音频改为在文档打开时自动播放,把同一个动作赋给 doc.AfterOpenAction 即可,但能否自动播放取决于阅读器,点击触发通常更稳妥:

第一页出现一张扬声器图标,点击它时会播放内嵌音频


创建内嵌文件跳转动作

内嵌文件跳转动作面向「文档夹带附件」的场景:附件先用 PdfAttachment 加进 Attachments 集合,再用 PdfEmbeddedGoToAction 指明打开哪个附件。

function App() {
  const addEmbeddedGoToAction = async () => {
    // 获取 Spire.PDF WASM 模块
    const pdfModule = window.wasmModule?.spirepdf;

    // 检查模块是否就绪
    if (!pdfModule) {
      alert('Spire.PDF is not ready yet');
      return;
    }

    // 将含中文字形的字体载入 VFS 的字体目录
    await window.spire.FetchFileToVFS('SIMSUN.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);

    // 将主文档与要内嵌的附件载入 VFS
    const inputFileName = '示例文档.pdf';
    await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}/data/`);
    const attachmentFileName = '附件.pdf';
    await window.spire.FetchFileToVFS(attachmentFileName, "", `${process.env.PUBLIC_URL}/data/`);

    // 创建 PdfDocument 对象并加载文档
    let doc = new pdfModule.PdfDocument();
    doc.LoadFromFile(inputFileName);

    // 获取第一页
    let firstPage = doc.Pages.get_Item(0);

    // 把附件 PDF 内嵌进文档
    let attachment = new pdfModule.PdfAttachment(attachmentFileName);
    doc.Attachments.Add({ attachment: attachment });

    // 目标:内嵌文件的首页
    let destination = new pdfModule.PdfDestination({ page: firstPage });
    destination.Location = new pdfModule.PointF(0, 842);
    destination.Zoom = 1;

    // 用附件名 + 目标构造内嵌跳转动作,true 表示在新窗口中打开
    let action = new pdfModule.PdfEmbeddedGoToAction(attachment.FileName, destination, true);

    // 在第一页画一个按钮,矩形即点击热区
    let bounds = new pdfModule.RectangleF({ x: 72, y: 720, width: 200, height: 28 });
    let font = new pdfModule.PdfTrueTypeFont({ fontFile: '/Library/Fonts/SIMSUN.TTF', size: 12 });
    let format = new pdfModule.PdfStringFormat({ alignment: pdfModule.PdfTextAlignment.Center, lineAlignment: pdfModule.PdfVerticalAlignment.Middle });
    firstPage.Canvas.DrawRectangle({ brush: pdfModule.PdfBrushes.get_LightGray(), rectangle: bounds });
    firstPage.Canvas.DrawString({ s: '打开内嵌附件', font: font, brush: pdfModule.PdfBrushes.get_Black(), layoutRectangle: bounds, format: format });

    // 动作注解覆盖按钮区域,点击即打开附件
    let annotation = new pdfModule.PdfActionAnnotation(bounds, action);
    firstPage.Annotations.Add(annotation);

    // 保存文档
    const outputFileName = '内嵌跳转动作.pdf';
    doc.SaveToFile(outputFileName);
    doc.Close();

    // 从 VFS 读取生成的文件,触发下载
    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], { type: 'application/pdf' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>创建内嵌文件跳转动作</h1>
      <button onClick={addEmbeddedGoToAction}>
        开始创建
      </button>
    </div>
  );
}

export default App;

第一页出现一个「打开内嵌附件」按钮,点击后在新窗口打开内嵌的附件 PDF:

第一页出现一个「打开内嵌附件」按钮,点击后在新窗口打开内嵌的附件 PDF


创建启动动作

启动动作把点击交给操作系统:文档里只记一条文件路径,点击时由系统用关联程序打开该文件,文件本身不在 PDF 内。

与上一节的内嵌文件跳转相比,关键差别在「文件在哪」:内嵌跳转把文件装进 PDF、落到附件里的某一页,文件随文档一起分发;启动动作只在文档里留下一条路径,PDF 本身不含该文件,目标文件必须放在同一目录(或可解析的路径)下,点击才有反应。不少阅读器出于安全考虑还会直接拦截启动动作。

function App() {
  const addLaunchAction = async () => {
    // 获取 Spire.PDF WASM 模块
    const pdfModule = window.wasmModule?.spirepdf;

    // 检查模块是否就绪
    if (!pdfModule) {
      alert('Spire.PDF is not ready yet');
      return;
    }

    // 将含中文字形的字体载入 VFS 的字体目录
    await window.spire.FetchFileToVFS('SIMSUN.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);

    // 将待处理的 PDF 载入 VFS
    const inputFileName = '示例文档.pdf';
    await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}/data/`);

    // 创建 PdfDocument 对象并加载文档
    let doc = new pdfModule.PdfDocument();
    doc.LoadFromFile(inputFileName);

    // 获取第一页
    let firstPage = doc.Pages.get_Item(0);

    // 用外部文件路径构造启动动作;Relative 表示相对文档所在目录
    const targetFileName = '附件.pdf';
    let action = new pdfModule.PdfLaunchAction(targetFileName, pdfModule.PdfFilePathType.Relative);
    action.IsNewWindow = true;

    // 在第一页画一个按钮,矩形即点击热区
    let bounds = new pdfModule.RectangleF({ x: 72, y: 720, width: 200, height: 28 });
    let font = new pdfModule.PdfTrueTypeFont({ fontFile: '/Library/Fonts/SIMSUN.TTF', size: 12 });
    let format = new pdfModule.PdfStringFormat({ alignment: pdfModule.PdfTextAlignment.Center, lineAlignment: pdfModule.PdfVerticalAlignment.Middle });
    firstPage.Canvas.DrawRectangle({ brush: pdfModule.PdfBrushes.get_LightGray(), rectangle: bounds });
    firstPage.Canvas.DrawString({ s: '打开外部文件', font: font, brush: pdfModule.PdfBrushes.get_Black(), layoutRectangle: bounds, format: format });

    // 动作注解覆盖按钮区域,点击即交给系统打开该文件
    let annotation = new pdfModule.PdfActionAnnotation(bounds, action);
    firstPage.Annotations.Add(annotation);

    // 保存文档
    const outputFileName = '启动动作.pdf';
    doc.SaveToFile(outputFileName);
    doc.Close();

    // 从 VFS 读取生成的文件,触发下载
    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], { type: 'application/pdf' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>创建启动动作</h1>
      <button onClick={addLaunchAction}>
        开始创建
      </button>
    </div>
  );
}

export default App;

第一页出现一个「打开外部文件」按钮,点击后由系统打开与 PDF 放在同一目录的 附件.pdf:

第一页出现一个「打开外部文件」按钮,点击后由系统打开与 PDF 放在同一目录的附件


常见问题

动作注解在页面上看不到可点击的样子

原因:动作由 PdfActionAnnotation 承载,它只框定热区,不绘制任何外观——落盘后注解字典里只有 /Rect 与 /A,没有 /AP 外观流。页面渲染器不会为它画边框或下划线,光挂一个注解,读者无从知道哪里能点。

解决:让热区落在自己画出来的图形上,用 Canvas 画底、写文字,注解与图形共用同一组坐标:

let bounds = new pdfModule.RectangleF({ x: 72, y: 720, width: 200, height: 28 });
firstPage.Canvas.DrawRectangle({ brush: pdfModule.PdfBrushes.get_LightGray(), rectangle: bounds });
firstPage.Canvas.DrawString({ s: '跳转到第 2 页', font: font, brush: pdfModule.PdfBrushes.get_Black(), layoutRectangle: bounds, format: format });
firstPage.Annotations.Add(new pdfModule.PdfActionAnnotation(bounds, action));

点击图片后声音动作没有反应

原因:动作与音频都已写入文档——注解字典里记着 /A /S /Sound,音频也以流的形式内嵌——但阅读器是否播放内嵌声音由它自己决定,部分阅读器(尤其是浏览器内置的 PDF 查看器)默认不播放内嵌音频。

解决:换用支持声音动作的阅读器(如 Adobe Acrobat),点击图片即会播放。这段音频始终随文档一起分发,不依赖任何外部文件。

跳转动作的落点位置上下颠倒

原因:PdfDestination.Location 与注解矩形一样,用的是左上角原点、y 向下的坐标,写进 PDF 后会换算成 PDF 原生的左下角坐标。按左下角的习惯给 y 值,读出来的落点就会和预期相反。

解决:一律按左上角原点给值,Location = new PointF(0, 0) 就表示页面顶部:

let destination = new pdfModule.PdfDestination({ page: secondPage });
destination.Mode = pdfModule.PdfDestinationMode.Location;
destination.Location = new pdfModule.PointF(0, 0);
destination.Zoom = 1;

点击后启动动作没有反应

原因:启动动作只在文档里留下一条文件路径(落盘为 /A << /S /Launch /F ... >>),目标文件并不在 PDF 内。文件不在文档同目录(相对路径)或路径无法解析时,点击自然没有反应;此外,出于安全考虑,许多阅读器会把启动动作整个拦下。

解决:把目标文件与 PDF 放在同一目录再打开;若希望文件随文档一起分发,改用内嵌文件跳转,把文件装进 PDF,而不是引用外部路径。


获取免费许可证

如果您希望删除结果文档中的评估消息,或者摆脱功能限制,请该Email地址已收到反垃圾邮件插件保护。要显示它您需要在浏览器中启用JavaScript。获取有效期 30 天的临时许可证。

阅读 9 次数