冰蓝科技
|
028-81705109
|
|
微信扫一扫
|

Spire.Cloud 纯前端文档控件

合同、报价单、财务报表这类文件一旦以 PDF 外发,内容基本就处在敞口状态——谁都能打开,也谁都能另存一份、改完再发出去。要在分发环节收一道口子,最直接的做法是给文档设密码,或者只放开阅读、把打印和复制关掉。这些操作过去要么依赖桌面软件,要么把文件传到服务端处理:前者很难嵌进 Web 流程,后者则意味着文档离开了用户的设备。

Spire.PDF for JavaScript 基于 WebAssembly 在浏览器端直接加载、修改与保存 PDF 文档,加密过程全部在本地完成,通过虚拟文件系统(VFS)读写文件,无需后端配合。

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

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


加密 PDF 文档

PdfPasswordSecurityPolicy 的构造函数接收打开密码与权限密码两个参数:拿到文档的一方需要前者才能打开,后者留给文档所有者,用于日后解除限制。加密算法通过 EncryptionAlgorithm 指定,这里选用 AES-128;DocumentPrivilege 则决定文档打开后允许执行哪些操作,get_AllowAll() 表示全部放行。

function App() {
  const encryptPdf = 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/`);

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

    // 创建密码安全策略:第一个参数是打开密码,第二个是权限密码
    const policy = new pdfModule.PdfPasswordSecurityPolicy('spire123', 'owner123');

    // 指定加密算法
    policy.EncryptionAlgorithm = pdfModule.PdfEncryptionAlgorithm.AES_128;

    // 指定权限策略,get_AllowAll() 表示不限制任何操作
    policy.DocumentPrivilege = pdfModule.PdfDocumentPrivilege.get_AllowAll();

    // 应用加密策略并保存文档
    doc.Encrypt(policy);
    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>加密 PDF 文档</h1>
      <button onClick={encryptPdf}>
        开始加密
      </button>
    </div>
  );
}

export default App;

设置打开密码与权限密码后的 PDF 文档

设置打开密码与权限密码后的 PDF 文档


限制 PDF 文档的操作权限

把打开密码留空、只设权限密码,文档就能免密打开,但打印、复制、修改这些操作会按权限设置逐项放行或禁止,很适合“可以看、带不走”的分发场景。权限本身用 PdfDocumentPrivilege 描述:先取一套全部放行的基准,再关掉不需要的项,这里关掉的是打印、复制内容和修改内容。

function App() {
  const restrictPdfPermissions = 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/`);

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

    // 打开密码留空:文档可以直接打开;权限密码用于日后解除限制
    const policy = new pdfModule.PdfPasswordSecurityPolicy('', 'owner123');
    policy.EncryptionAlgorithm = pdfModule.PdfEncryptionAlgorithm.AES_128;

    // 以全部放行为基准,逐项关闭不需要的权限
    const privilege = pdfModule.PdfDocumentPrivilege.get_AllowAll();
    privilege.AllowPrint = false;
    privilege.AllowContentCopying = false;
    privilege.AllowModifyContents = false;
    policy.DocumentPrivilege = privilege;

    // 应用加密策略并保存文档
    doc.Encrypt(policy);
    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>限制 PDF 操作权限</h1>
      <button onClick={restrictPdfPermissions}>
        开始设置
      </button>
    </div>
  );
}

export default App;

免密码打开、但打印与复制等操作已被禁止的 PDF 文档

免密码打开、但打印与复制等操作已被禁止的 PDF 文档


解密 PDF 文档

解密就是把已有的密码保护去掉,前提是拿得到密码。如果手上只有打开密码,Decrypt 需要再给出权限密码才能解除限制,只凭打开密码调用无参的 Decrypt() 会被拒绝。

function App() {
  const decryptPdf = 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/`);

    // 创建 PdfDocument 对象,并用打开密码载入加密文档
    let doc = new pdfModule.PdfDocument();
    doc.LoadFromFile(inputFileName, 'spire123');

    // 确认文档确实受密码保护
    if (!doc.IsEncrypted) {
      alert('该文档未加密,无需解密');
      return;
    }

    // 用权限密码移除密码保护
    doc.Decrypt('owner123');

    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>解密 PDF 文档</h1>
      <button onClick={decryptPdf}>
        开始解密
      </button>
    </div>
  );
}

export default App;

移除密码保护后的 PDF 文档,可直接打开

移除密码保护后的 PDF 文档,可直接打开


常见问题

打开加密文档时提示密码无效

原因:调用 LoadFromFile 时没有传密码,或者传进去的密码与文档的打开密码不一致。这种情况下 Spire.PDF 会直接抛出 Can not open an encrypted document. The password is invalid.,而不会返回一个内容为空的 PdfDocument。

解决:把打开密码作为 LoadFromFile 的第二个参数传入即可:

// 第二个参数即打开密码
doc.LoadFromFile(inputFileName, 'spire123');

调用 Decrypt() 时报 "Cannot decrypt documents without permission password"

原因:文档是用打开密码载入的,此时只具备阅读权限。移除加密属于权限级操作,必须提供权限密码(也称所有者密码)才能完成。

解决:两种写法都可行——用权限密码载入后调用无参的 Decrypt(),或者保留打开密码载入、把权限密码交给 Decrypt:

// 写法一:用权限密码载入,再直接移除保护
doc.LoadFromFile(inputFileName, 'owner123');
doc.Decrypt();

// 写法二:用打开密码载入,把权限密码传给 Decrypt
doc.LoadFromFile(inputFileName, 'spire123');
doc.Decrypt('owner123');

加密算法该选哪一个

原因:PdfEncryptionKeySize 与 PdfEncryptionAlgorithm 列出了 RC4_40、RC4_128、AES_128、AES_256 等选项,但浏览器端的 WebAssembly 实现目前不支持 AES-256,把 EncryptionAlgorithm 设为 AES_256 会抛出 Cryptography_AlgorithmNotSupported。

解决:Web 端使用 AES_128;确实需要兼容只认 RC4 的旧阅读器时,可以改用 RC4_128:

// Web 端推荐:AES-128
policy.EncryptionAlgorithm = pdfModule.PdfEncryptionAlgorithm.AES_128;

// 需要兼容旧阅读器时:RC4-128
policy.EncryptionAlgorithm = pdfModule.PdfEncryptionAlgorithm.RC4_128;

获取免费许可证

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

JavaScript 文本转 PDF

将纯文本文件转换为 PDF,可以把简单的文本内容转换成具有固定页面布局的文档,从而更加方便地进行分享、存档、打印和分发。与 TXT 文件相比,PDF 还可以对页面尺寸、页边距、字体、文本对齐方式以及分页方式进行更加灵活的控制。

本文将介绍如何在 React 应用程序中使用 Spire.PDF for JavaScript 通过 JavaScript 将 TXT 文件转换为 PDF。此外,还将介绍一些常用的页面和文本设置,包括设置 PDF 页面尺寸和页边距、使用支持中文的自定义字体、更改文本对齐方式,以及控制文本在页面中的起始位置。

本文内容:

在 React 中配置 Spire.PDF for JavaScript

在开始处理 PDF 文件之前,需要先确保已经将 Spire.PDF for JavaScript 集成到 React 项目中,并且能够正常加载其 WebAssembly 模块。

如果尚未完成相关配置,可以参考教程:如何在 React 项目中集成 Spire.PDF for JavaScript。

下面的示例默认已经将所需的 JavaScript、WebAssembly 以及相关支持文件添加到了 React 项目的 public 目录中,并且可以通过以下方式访问 Spire.PDF 模块:

window.wasmModule.spirepdf

此外,本示例使用的源 TXT 文件也需要放置在应用程序 public 目录下可以访问的位置。

使用 JavaScript 将文本转换为 PDF

将 TXT 文件转换为 PDF 主要包含以下几个步骤。

首先,将 TXT 文件加载到 WebAssembly 虚拟文件系统(VFS)中,然后以字节形式读取文件内容,并将其解码为 JavaScript 字符串。

接下来,创建一个新的 PDF 文档并添加页面。可以使用 PdfTextWidget 将文本绘制到 PDF 页面中。通过启用 PdfTextLayout 的自动分页功能,当文本内容超出当前页面的可用区域时,可以自动继续绘制到后续页面,而不是只显示在第一页。

由于 PDF 内置字体(如 Helvetica)并不能完整支持中文字符,因此本示例加载 Microsoft YaHei(微软雅黑) 字体,并通过 PdfTrueTypeFont 创建支持 Unicode 的字体对象,以确保中文文本能够正确显示。

最后,将生成的 PDF 保存到虚拟文件系统中,再读取生成的 PDF 数据并转换为 Blob,从而允许用户直接在浏览器中下载文件。

以下代码演示了完整的实现过程:

import React, { useEffect, useState } from 'react';

function App() {
  const [ready, setReady] = useState(false);
  const [downloadUrl, setDownloadUrl] = useState(null);
  const [downloadName, setDownloadName] = useState('');

  useEffect(() => {
    (async () => {
      const publicUrl = process.env.PUBLIC_URL || '';
      await import(/* webpackIgnore: true */ `${publicUrl}/spire.common.js`);
      const spireModule = await import(/* webpackIgnore: true */ `${publicUrl}/spire.pdf.js`);
      const rawModule = spireModule.default || spireModule;

      window.wasmModule = typeof rawModule === 'function'
        ? await rawModule({ locateFile: p => p.endsWith('.wasm') ? `${publicUrl}/${p}` : p })
        : rawModule;

      setReady(true);
    })();
  }, []);

  const textToPdf = async () => {
    const wasmModule = window.wasmModule.spirepdf;
    if (!wasmModule) return;

    // 1. 将文本文件加载到虚拟文件系统(VFS)
    const inputFileName = 'TextToPdf.txt';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL || ''}/`);

    // 2. 读取 TXT 文件中的文本
    const textByte = window.dotnetRuntime.Module.FS.readFile(inputFileName);
    const text = new TextDecoder('utf-8').decode(textByte);

    // 3. 创建 PDF 文档
    const doc = new wasmModule.PdfDocument();

    // 4. 添加一个 Section
    const section = doc.Sections.Add();

    // 5. 添加 PDF 页面
    const page = section.Pages.Add();

    // 6. 加载微软雅黑字体,并创建支持 Unicode 的 PdfTrueTypeFont
    await window.spire.FetchFileToVFS(
      'msyh.ttc',
      '/Library/Fonts/',
      `${process.env.PUBLIC_URL}/fonts/`
    );

    let font = new wasmModule.PdfTrueTypeFont({
      fontFamily: 'Microsoft YaHei',
      size: 12,
      style: wasmModule.PdfFontStyle.Regular,
      unicode: true
    });
    
    // 7. 设置文本格式
    const format = new wasmModule.PdfStringFormat();
    format.Alignment = wasmModule.PdfTextAlignment.Left;
    format.LineSpacing = 20;

    // 8. 设置文本颜色
    const brush = wasmModule.PdfBrushes.get_Black();

    // 9. 设置文本布局和分页方式
    const textLayout = new wasmModule.PdfTextLayout();
    textLayout.Break = wasmModule.PdfLayoutBreakType.FitPage;
    textLayout.Layout = wasmModule.PdfLayoutType.Paginate;

    // 10. 定义文本在 PDF 页面中的绘制区域
    const bounds = new wasmModule.RectangleF({
      location: new wasmModule.PointF(0, 0),
      size: page.Canvas.ClientSize,
    });

    // 11. 创建 PdfTextWidget
    const textWidget = new wasmModule.PdfTextWidget({ text, font, brush });
    textWidget.StringFormat = format;

    // 12. 将文本绘制到 PDF 页面中
    const layoutWidget = new wasmModule.PdfLayoutWidget(textWidget.H);
    layoutWidget.Draw({
      page,
      layoutRectangle: bounds,
      format: textLayout
    });

    // 13. 定义输出文件名称
    const outputFileName = 'TextToPdf_result.pdf';

    // 14. 保存并关闭 PDF 文档
    doc.SaveToFile(outputFileName);
    doc.Close();

    // 15. 读取生成的 PDF 文件,并转换为 Blob
    const bytes = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([bytes], { type: 'application/pdf' });

    // 16. 生成下载链接
    setDownloadName(outputFileName);
    setDownloadUrl(URL.createObjectURL(blob));
  };

  return (
    <div style={{ textAlign: 'center', padding: 30 }}>
      <h1>文本转 PDF</h1>
      <span>点击下面的按钮将文本文件转换为 PDF 文档。</span>

      <div style={{ marginTop: '20px' }}>
        <button onClick={textToPdf} disabled={!ready}>
          转换为 PDF
        </button>

        {downloadUrl && (
          <div style={{ marginTop: '10px' }}>
            <a href={downloadUrl} download={downloadName}>
              点击此处下载生成的 PDF 文件
            </a>
          </div>
        )}
      </div>
    </div>
  );
}

export default App;

在上述示例中,TextDecoder 用于将 TXT 文件中的 UTF-8 字节数据转换为 JavaScript 字符串。

文本内容随后被传递给 PdfTextWidget,而 PdfLayoutWidget.Draw() 负责实际的文本布局和绘制。由于使用了 PdfLayoutType.Paginate,当文本内容超过第一页的可用区域时,剩余内容会自动继续绘制到后续页面。

输出效果:

使用 JavaScript 将文本转换为 PDF

PDF 页面和文本设置

设置 PDF 页面尺寸和页边距

在将较长的文本转换为 PDF 时,页面尺寸和页边距会直接影响每一页可以容纳的文本数量以及最终的版面效果。

例如,可以将 PDF 页面设置为 A4,并将上下左右四个方向的页边距均设置为 20 点:

const page = section.Pages.Add();

page.PageSettings.Size = wasmModule.PdfPageSize.A4;

page.PageSettings.Margins = new wasmModule.PdfMargins({
  top: 20,
  bottom: 20,
  left: 20,
  right: 20
});

页边距会缩小文本可使用的绘制区域,同时避免文本距离 PDF 页面边缘过近。

可以根据生成文档的实际用途调整这些数值。例如,报告、打印文档通常适合使用较大的页边距;如果希望每页容纳更多文本,则可以适当减小页边距。

使用支持中文的自定义字体

PDF 内置字体(如 Helvetica)主要适用于英文及部分西文字符,并不能完整支持中文字符。因此,在将包含中文内容的 TXT 文件转换为 PDF 时,需要加载支持中文和 Unicode 字符的 TrueType 字体。

本示例使用 Microsoft YaHei(微软雅黑) 字体。首先,将字体文件 msyh.ttc 加载到 WebAssembly 虚拟文件系统中:

await window.spire.FetchFileToVFS(
  'msyh.ttc',
  '/Library/Fonts/',
  `${process.env.PUBLIC_URL}/fonts/`
);

然后通过 PdfTrueTypeFont 创建字体对象:

let font = new wasmModule.PdfTrueTypeFont({
  fontFamily: 'Microsoft YaHei',
  size: 12,
  style: wasmModule.PdfFontStyle.Regular,
  unicode: true
});

其中:

  • fontFamily 用于指定字体名称,这里使用 Microsoft YaHei。
  • size 用于设置文本字号。
  • style 用于设置字体样式,例如常规、粗体或斜体。
  • unicode: true 用于启用 Unicode 字符支持,这对于正确显示中文字符非常重要。

在 React 项目中,可以将字体文件放置在:

public/fonts/

目录下,然后通过 FetchFileToVFS() 将其加载到 WebAssembly 环境中。

完成字体加载后,在创建 PdfTextWidget 时使用该字体,即可将中文文本正确绘制到 PDF 页面中:

const textWidget = new wasmModule.PdfTextWidget({
  text,
  font,
  brush
});

除了微软雅黑之外,也可以根据实际需求使用其他支持中文字符的字体。无论选择哪种字体,都需要确保所使用的字体文件能够覆盖待转换文本中包含的字符。

更改文本对齐方式

文本的对齐方式可以通过 PdfStringFormat 进行控制。

例如,下面的代码将文本设置为两端对齐:

const format = new wasmModule.PdfStringFormat();

format.Alignment =
  wasmModule.PdfTextAlignment.Justify;

可以根据文档的实际排版需求设置不同的文本对齐方式。

对于普通段落,左对齐通常比较适合中文文本;两端对齐也可以用于需要更加整齐版面的正文内容。标题、页眉或其他特殊文本则可以根据需要设置为居中或其他对齐方式。

同一个 PdfStringFormat 对象还可以用于控制行距,例如:

format.LineSpacing = 20;

适当增加行距可以提高长篇文本转换为 PDF 后的可读性。

控制文本在 PDF 页面中的起始位置

在 PDF 页面中绘制文本时,RectangleF 对象用于定义文本的布局区域。

其中,文本的起始位置由 PointF 对象控制:

const bounds = new wasmModule.RectangleF({
  location: new wasmModule.PointF(0, y),
  size: page.Canvas.ClientSize,
});

这里的 y 值用于控制文本起始位置与 PDF 页面顶部之间的距离。

例如:

location: new wasmModule.PointF(0, 30)

会将文本的起始位置向下移动 30 点。

当绘制区域使用 page.Canvas.ClientSize 时,通常建议将 x 坐标保持为 0 。

如果增加 x 坐标,但没有相应减小文本绘制区域的宽度,可能会导致左右两侧留白不一致。在某些情况下,靠近页面右侧的文本还可能超出有效绘制区域,从而出现内容被截断的问题。

因此,如果只是希望增加文本第一行与页面顶部之间的距离,更推荐仅调整 y 坐标:

const bounds = new wasmModule.RectangleF({
  location: new wasmModule.PointF(0, 40),
  size: page.Canvas.ClientSize,
});

这样可以让文本从距离默认顶部 (除去空白边缘) 位置 40 点的位置开始绘制,同时保留完整的页面可用宽度。

总结

在 React 应用程序中将纯文本转换为 PDF,并不只是简单地更改文件扩展名。首先需要读取并解码 TXT 文件中的文本,然后使用合适的字体、文本格式和页面布局规则将内容绘制到 PDF 页面中。

通过 Spire.PDF for JavaScript,可以直接在 React 应用程序中根据 TXT 内容生成 PDF,并进一步控制 页面尺寸、页边距、中文字体、文本对齐方式、行距、起始位置以及自动分页 等属性。

对于中文文本,由于 Helvetica 等 PDF 内置字体无法完整覆盖中文字符,还需要加载支持中文和 Unicode 的 TrueType 字体,例如微软雅黑,从而确保生成的 PDF 能够正确显示中文内容。

通过这些设置,可以将简单的纯文本内容转换为结构更加清晰、版面更加规范且便于分享和打印的 PDF 文档。

常见问题

可以使用 JavaScript 在 React 中将 TXT 文件转换为 PDF 吗?

可以。React 应用程序可以读取 TXT 文件中的文本内容,然后使用 Spire.PDF for JavaScript 等 JavaScript PDF 库创建 PDF 页面,并将文本绘制到页面中。

如何将较长的文本自动转换到多个 PDF 页面?

可以结合使用 PdfTextLayout 和 PdfLayoutType.Paginate。当当前页面没有足够空间容纳剩余文本时,PdfTextWidget 中的内容可以自动继续绘制到后续页面:

const textLayout = new wasmModule.PdfTextLayout();

textLayout.Break =
  wasmModule.PdfLayoutBreakType.FitPage;

textLayout.Layout =
  wasmModule.PdfLayoutType.Paginate;

将文本转换为 PDF 时可以指定页面尺寸吗?

可以。可以通过 PDF 页面的 PageSettings 设置页面尺寸。例如,下面的代码将页面尺寸设置为 A4:

page.PageSettings.Size =
  wasmModule.PdfPageSize.A4;

还可以分别设置页面顶部、底部、左侧和右侧的页边距,以控制实际可用于绘制文本的区域。

为什么中文文本转换为 PDF 时需要使用自定义字体?

Helvetica 等 PDF 内置字体并不能完整支持中文字符。因此,在生成包含中文内容的 PDF 时,需要使用支持中文和 Unicode 的 TrueType 字体,例如 Microsoft YaHei。

首先将字体文件加载到 WebAssembly 虚拟文件系统:

await window.spire.FetchFileToVFS(
  'msyh.ttc',
  '/Library/Fonts/',
  `${process.env.PUBLIC_URL}/fonts/`
);

然后创建启用了 Unicode 支持的 PdfTrueTypeFont:

let font = new wasmModule.PdfTrueTypeFont({
  fontFamily: 'Microsoft YaHei',
  size: 12,
  style: wasmModule.PdfFontStyle.Regular,
  unicode: true
});

这样可以确保中文字符在生成的 PDF 中被正确显示。

如何让文本从 PDF 页面更靠下的位置开始显示?

可以修改用于定义文本绘制区域的 PointF 对象中的 y 坐标:

const bounds = new wasmModule.RectangleF({
  location: new wasmModule.PointF(0, 40),
  size: page.Canvas.ClientSize,
});

y 值越大,文本距离 PDF 页面顶部的起始位置就越远。

获取免费许可证

如果您需要去除生成文档中的评估提示或解除功能限制,请联系我们获取有效期 30 天的临时许可证。

PDF 文档可以携带附件(Attachment),把图片、表格、补充说明等文件随文档一起分发,这种“文档包”形式在合同、报价单、报告等场景中很常见。PDF 中的附件有两种存在形式:一种是文档附件,挂在整份文档上,在阅读器的“附件”面板里统一列出;另一种是注释附件,作为页面上的回形针图标(Paperclip)出现,双击即可打开所附文件。当我们拿到一份带附件的 PDF 时,往往需要把附件取出来单独使用,而这两种附件的读取方式并不相同,需要用不同的接口分别处理。

Spire.PDF for JavaScript 基于 WebAssembly 在浏览器端直接处理 PDF 文档,通过虚拟文件系统(VFS)管理输入输出文件,无需后端服务。两类附件的读取入口不同,但取到后都通过 FileName 与 Data 拿到附件的名称与内容:文档附件用 PdfDocument.Attachments 配 PdfEmbeddedFileSpecification;注释附件需逐页访问 PdfPage.Annotations,按类型筛出 PdfAttachmentAnnotationWidget。

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

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


相关知识

PDF 文件中的附件分为两类:文档级附件和注释级附件。下表说明了两类附件之间的差异,以及它们在 Spire.PDF for JavaScript 中的表示方式。

附件类型 表示方式 定义
文档附件 PdfDocument.Attachments,经 PdfEmbeddedFileSpecification 读取 以文档级添加的 PDF 附件不会显示在 PDF 页面上,但可以在 PDF 阅读器的“附件”面板中查看。
注释附件 PdfAttachmentAnnotationWidget 作为注释附加的文件可以在页面上或“附件”面板中找到。注释附件在页面上显示为一个纸夹图标;阅读文档时可以双击该图标打开文件。

提取 PDF 文档中的附件

PdfDocument.Attachments 返回文档级的全部附件。遍历该集合,用 new PdfEmbeddedFileSpecification(attachment.H) 包装每个附件后,即可通过其 FileName 与 Data 把附件内容写入 VFS;全部写出后,再借助 JSZip 将它们打包成一个 zip 文件下载。这种方式适合一次性留存或迁移文档中的全部附件。

import JSZip from 'jszip';

function App() {
  const extractDocumentAttachments = 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/`);

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

    // 获取文档中的附件集合
    let collection = doc.Attachments;

    // 在 VFS 中创建临时目录,用于存放解压出的附件
    const outputDirectoryName = 'attachmentFiles/';
    window.dotnetRuntime.Module.FS.mkdirTree(outputDirectoryName);

    // 逐个取出附件,按附件自身的文件名写入临时目录
    for (let i = 0; i < collection.Count; i++) {
      let attachment = collection.get_Item(i);

      // 通过底层句柄 H 构造 PdfEmbeddedFileSpecification,读取附件内容
      let embeddedFileSpecification = new pdfModule.PdfEmbeddedFileSpecification(attachment.H);
      window.dotnetRuntime.Module.FS.writeFile(
        outputDirectoryName + embeddedFileSpecification.FileName,
        embeddedFileSpecification.Data
      );
    }

    // 释放文档资源
    doc.Close();

    // 将临时目录中的全部附件打包为一个 zip 文件
    const zip = new JSZip();
    let items = await window.dotnetRuntime.Module.FS.readdir(outputDirectoryName);
    items = items.filter((item) => item !== '.' && item !== '..');
    for (const item of items) {
      const fileData = window.dotnetRuntime.Module.FS.readFile(outputDirectoryName + item);
      zip.file(item, fileData);
    }
    const zipBlob = await zip.generateAsync({ type: 'blob' });

    // 触发下载
    const outputFileName = '文档附件.zip';
    const url = URL.createObjectURL(zipBlob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>提取 PDF 文档中的附件</h1>
      <button onClick={extractDocumentAttachments}>
        开始提取
      </button>
    </div>
  );
}

export default App;

文档级附件批量导出后打包成的 zip 文件

文档级附件批量导出后打包成的 zip 文件


提取 PDF 注释中的附件

注释附件的读取方式与文档附件不同:它属于页面上的注释,无法通过 doc.Attachments 取得。需要先遍历 doc.Pages 逐页访问 PdfPage.Annotations,用 instanceof 判断每个注释是否为 PdfAttachmentAnnotationWidget(附件注释),命中的注释其 FileName 与 Data 就是所附文件的名称与内容。由于附件注释可能分布在不同页面,外层必须完整遍历所有页面,才能把文档中的注释附件全部取出。

import JSZip from 'jszip';

function App() {
  const extractAnnotationAttachments = 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/`);

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

    // 在 VFS 中创建临时目录,用于存放解压出的附件
    const outputDirectoryName = 'annotationFiles/';
    window.dotnetRuntime.Module.FS.mkdirTree(outputDirectoryName);

    // 逐页遍历,从注释中提取附件
    for (let p = 0; p < doc.Pages.Count; p++) {
      let page = doc.Pages.get_Item(p);

      // 获取当前页的注释集合
      let annotations = page.Annotations;

      for (let i = 0; i < annotations.Count; i++) {
        let annotation = annotations.get_Item(i);

        // 仅处理附件注释,其余注释(文本、链接等)跳过
        if (annotation instanceof pdfModule.PdfAttachmentAnnotationWidget) {
          // FileName 为附件文件名,Data 为附件的二进制内容
          window.dotnetRuntime.Module.FS.writeFile(
            outputDirectoryName + annotation.FileName,
            annotation.Data
          );
        }
      }
    }

    // 释放文档资源
    doc.Close();

    // 将临时目录中的全部附件打包为一个 zip 文件
    const zip = new JSZip();
    let items = await window.dotnetRuntime.Module.FS.readdir(outputDirectoryName);
    items = items.filter((item) => item !== '.' && item !== '..');
    for (const item of items) {
      const fileData = window.dotnetRuntime.Module.FS.readFile(outputDirectoryName + item);
      zip.file(item, fileData);
    }
    const zipBlob = await zip.generateAsync({ type: 'blob' });

    // 触发下载
    const outputFileName = '注释附件.zip';
    const url = URL.createObjectURL(zipBlob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>提取 PDF 注释中的附件</h1>
      <button onClick={extractAnnotationAttachments}>
        开始提取
      </button>
    </div>
  );
}

export default App;

从页面注释中提取出的附件打包成的 zip 文件

从页面注释中提取出的附件打包成的 zip 文件


常见问题

为什么 doc.Attachments 里没有页面上看到的回形针附件

原因:PDF 中的附件分为文档级与注释级两种。PdfDocument.Attachments 只返回文档级附件(在阅读器“附件”面板中列出),而页面上的回形针图标属于注释级附件,它是页面注释的一部分,不会出现在 Attachments 集合中。

解决:提取注释附件要逐页访问页面注释集合,并按类型筛出附件注释:

for (let p = 0; p < doc.Pages.Count; p++) {
  let annotations = doc.Pages.get_Item(p).Annotations;
  for (let i = 0; i < annotations.Count; i++) {
    if (annotations.get_Item(i) instanceof pdfModule.PdfAttachmentAnnotationWidget) {
      // 处理附件注释
    }
  }
}

为什么遍历 page.Annotations 时必须先做类型判断

原因:页面上可以并存多种注释,例如文本注释、链接注释、图章注释等,它们的属性各不相同。只有附件注释 PdfAttachmentAnnotationWidget 才提供 FileName 与 Data,直接对任意注释读取这两个属性并不可靠。

解决:用 instanceof PdfAttachmentAnnotationWidget 先判断类型,再读取属性:

let annotation = annotations.get_Item(i);
if (annotation instanceof pdfModule.PdfAttachmentAnnotationWidget) {
  let fileName = annotation.FileName;
  let data = annotation.Data;
}

为什么只提取到了部分注释附件

原因:注释附件是挂在具体页面上的,不同页都可能有。如果只访问 doc.Pages.get_Item(0) 这一页,其余页面上的附件注释就会被漏掉。

解决:外层遍历 doc.Pages,把每一页的注释集合都检查一遍:

for (let p = 0; p < doc.Pages.Count; p++) {
  let annotations = doc.Pages.get_Item(p).Annotations;
  // 逐个检查该页的注释
}

获取免费许可证

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

命名区域建立之后并非一劳永逸。随着数据表结构调整,原有的名称可能不再贴切,引用范围也可能因数据增减而失效;有些命名区域只是公式的中间辅助,并不需要出现在名称管理器中;而长期不再使用的命名区域如果一直保留,又会让名称列表变得冗长难查。因此,修改、隐藏与删除同样是命名区域使用过程中不可或缺的环节。Spire.XLS for JavaScript 提供了完整的命名区域管理 API,可在浏览器端通过 WebAssembly 完成上述操作,无需后端服务。

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

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


修改命名区域

修改包含两个相互独立的方面:名称本身与引用区域。名称通过 Name 属性重新赋值,引用区域通过 RefersToRange 属性重新指定,两者可以分别修改,也可以像下面的示例一样同时修改。具体操作步骤如下:

  1. 加载工作簿,获取第一个工作表。
  2. 通过 workbook.NameRanges.get(0) 取出要修改的命名区域。
  3. 将 Name 设为新的名称。
  4. 将 RefersToRange 指向新的单元格区域。
  5. 保存工作簿。

下面是一个完整的代码示例,展示了在 React 中修改命名区域:

function App() {
  const modifyNamedRange = async () => {
    // 获取 Spire.XLS WASM 模块
    const xlsModule = window.wasmModule?.spirexls;

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

    // 将中文字体载入 VFS
    await window.spire.FetchFileToVFS('simsun.ttc', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);

    // 将 Excel 文件载入 VFS
    const inputFileName = 'AllNamedRanges.xlsx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/static/data/`);

    // 加载工作簿
    const workbook = new xlsModule.Workbook();
    workbook.LoadFromFile(inputFileName);

    // 获取第一个工作表
    let sheet = workbook.Worksheets.get(0);

    // 修改命名区域的名称
    workbook.NameRanges.get(0).Name = "RegionData";

    // 修改命名区域引用的单元格区域
    workbook.NameRanges.get(0).RefersToRange = sheet.Range.get("B2:C4");

    // 保存工作簿
    const outputFileName = 'ModifyNamedRange.xlsx';
    workbook.SaveToFile(outputFileName);

    // 释放资源
    workbook.Dispose();

    // 从 VFS 读取结果文件,触发下载
    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
    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={modifyNamedRange}>Start</button>
    </div>
  );
}

export default App;

运行后,修改命名区域的效果:

修改命名区域


隐藏命名区域

把 Visible 属性设为 false,命名区域即被隐藏。隐藏后的命名区域依然保存在工作簿中,公式中引用它也不受影响,只是不再出现在 Excel 的名称管理器与名称框里,从而让名称列表保持整洁。下面的示例在隐藏之后,又于 F2 单元格写入了引用该命名区域的公式 =SUM(NameRange1):公式照常算出结果,正说明命名区域只是不显示,并未被删除。具体操作步骤如下:

  1. 加载工作簿,获取第一个工作表。
  2. 取出要隐藏的命名区域。
  3. 将 Visible 设为 false。
  4. 在单元格中写入引用该命名区域的公式,验证隐藏后依然可用。
  5. 保存工作簿。

下面是一个完整的代码示例,展示了在 React 中隐藏命名区域:

function App() {
  const hideNamedRange = async () => {
    // 获取 Spire.XLS WASM 模块
    const xlsModule = window.wasmModule?.spirexls;

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

    // 将中文字体载入 VFS
    await window.spire.FetchFileToVFS('simsun.ttc', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);

    // 将 Excel 文件载入 VFS
    const inputFileName = 'AllNamedRanges.xlsx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/static/data/`);

    // 加载工作簿
    const workbook = new xlsModule.Workbook();
    workbook.LoadFromFile(inputFileName);

    // 获取第一个工作表
    let sheet = workbook.Worksheets.get(0);

    // 隐藏第一个命名区域
    workbook.NameRanges.get(0).Visible = false;

    // 在单元格中写入引用该命名区域的公式,验证隐藏后它依然存在且可用
    sheet.Range.get("F1").Text = "隐藏后求和";
    sheet.Range.get("F2").Formula = "=SUM(NameRange1)";

    // 计算公式,使保存后的文件打开即可看到结果
    workbook.CalculateAllValue();

    // 保存工作簿
    const outputFileName = 'HideNamedRange.xlsx';
    workbook.SaveToFile(outputFileName);

    // 释放资源
    workbook.Dispose();

    // 从 VFS 读取结果文件,触发下载
    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
    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={hideNamedRange}>Start</button>
    </div>
  );
}

export default App;

运行后,隐藏命名区域的效果:

隐藏命名区域

说明:F2 单元格的公式引用的是已被隐藏的 NameRange1,结果照常算出 120,说明隐藏只是让它不再显示,命名区域本身依然存在于工作簿中。


删除命名区域

删除命名区域有两种方式:已知名称时调用 Remove(),已知位置时调用 RemoveAt()。两者都会把命名区域从工作簿中彻底移除。具体操作步骤如下:

  1. 加载工作簿。
  2. 调用 Remove() 按名称删除指定命名区域。
  3. 调用 RemoveAt() 按索引删除指定命名区域。
  4. 保存工作簿。

下面是一个完整的代码示例,展示了在 React 中删除命名区域:

function App() {
  const deleteNamedRange = async () => {
    // 获取 Spire.XLS WASM 模块
    const xlsModule = window.wasmModule?.spirexls;

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

    // 将中文字体载入 VFS
    await window.spire.FetchFileToVFS('simsun.ttc', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);

    // 将 Excel 文件载入 VFS
    const inputFileName = 'AllNamedRanges.xlsx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/static/data/`);

    // 加载工作簿
    const workbook = new xlsModule.Workbook();
    workbook.LoadFromFile(inputFileName);

    // 按名称删除命名区域
    workbook.NameRanges.Remove("NameRange2");

    // 按索引删除命名区域
    workbook.NameRanges.RemoveAt(0);

    // 保存工作簿
    const outputFileName = 'DeleteNamedRange.xlsx';
    workbook.SaveToFile(outputFileName);

    // 释放资源
    workbook.Dispose();

    // 从 VFS 读取结果文件,触发下载
    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
    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={deleteNamedRange}>Start</button>
    </div>
  );
}

export default App;

运行后,删除命名区域的效果:

删除命名区域


常见问题

公式写入单元格后,保存的文件打开为什么看不到结果?

原因:只给单元格设置 Formula 属性,Spire 不会顺带计算公式,保存出来的文件里只有公式本身,没有算好的结果值,Excel 打开后这一格就是空白。

解决:保存之前调用 workbook.CalculateAllValue(),先把公式计算一遍:

// 计算全部公式,结果值才会一并写入保存的文件
workbook.CalculateAllValue();

删除命名区域时可以直接传入命名区域对象吗?

原因:Remove() 的参数是名称字符串,传入 NameRange 对象类型不匹配,会抛出 Assert failed: Value is not a String,删除不会执行。

解决:已知名称时传名称字符串,已知位置时传索引:

// 按名称删除
workbook.NameRanges.Remove("NameRange2");

// 按索引删除
workbook.NameRanges.RemoveAt(0);

获取免费许可证

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

在 Excel 中,公式通常需要直接写入具体的单元格区域,例如 =SUM(D2:D10)。这类公式一旦增多,维护成本便随之上升:数据范围调整时,所有相关公式都需逐一修改,遗漏一处即会导致结果出错。命名区域(Named Range)正是为解决这一问题而设计——为单元格区域取一个有含义的名称,公式中直接引用该名称即可。区域与公式由此得以分离:调整范围只需修改一处,所有引用它的公式都会自动更新,既降低了出错的可能,也让公式更易阅读。Spire.XLS for JavaScript 提供完整的命名区域 API,支持在浏览器端通过 WebAssembly 创建全局(工作簿级)与局部(工作表级)命名区域,无需后端服务。

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

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


全局命名区域

全局命名区域保存在工作簿的名称集合 Workbook.NameRanges 中,名称在整个工作簿内唯一,任意工作表都可以直接引用它。通过 Workbook.NameRanges.Add() 方法创建,再用 RefersToRange 属性指定它指向的单元格区域。具体操作步骤如下:

  1. 加载包含数据的 Excel 文件,并获取第一个工作表。
  2. 通过 workbook.NameRanges.Add("SalesData") 创建全局命名区域。
  3. 将 namedRange.RefersToRange 设置为 sheet.Range.get("A1:D10"),即区域 A1:D10。
  4. 读取 namedRange.Name 与 namedRange.RefersToRange.RangeAddress,把名称和引用地址写回单元格。
  5. 通过 Workbook.SaveToFile() 方法保存工作簿。

下面是一个完整的代码示例,展示了在 React 中创建全局命名区域:

function App() {
  const createGlobalNamedRange = async () => {
    // 获取 Spire.XLS WASM 模块
    const xlsModule = window.wasmModule?.spirexls;

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

    // 将中文字体载入 VFS
    await window.spire.FetchFileToVFS('simsun.ttc', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);

    // 将 Excel 文件载入 VFS
    const inputFileName = 'NamedRanges.xlsx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/static/data/`);

    // 加载工作簿
    const workbook = new xlsModule.Workbook();
    workbook.LoadFromFile(inputFileName);

    // 获取第一个工作表
    let sheet = workbook.Worksheets.get(0);

    // 创建工作簿级(全局)命名区域
    let namedRange = workbook.NameRanges.Add("SalesData");

    // 设置命名区域指向的单元格区域
    namedRange.RefersToRange = sheet.Range.get("A1:D10");

    // 读取命名区域的名称与引用地址
    sheet.Range.get("F1").Text = "命名区域名称";
    sheet.Range.get("F2").Text = namedRange.Name;
    sheet.Range.get("G1").Text = "引用地址";
    sheet.Range.get("G2").Text = namedRange.RefersToRange.RangeAddress;

    // 自适应列宽
    sheet.AllocatedRange.AutoFitColumns();

    // 保存工作簿
    const outputFileName = 'GlobalNamedRange.xlsx';
    workbook.SaveToFile(outputFileName);

    // 释放资源
    workbook.Dispose();

    // 从 VFS 读取结果文件,触发下载
    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
    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={createGlobalNamedRange}>Start</button>
    </div>
  );
}

export default App;

运行后,创建全局命名区域的效果:

创建全局命名区域


局部命名区域

全局命名区域要求名称在整个工作簿内唯一。如果不同工作表都需要使用一个相同的名字,却各自指向不同的数据区域,就应改用局部命名区域——通过 Worksheet.Names.Add() 添加,名称只在所属工作表内生效,同名区域可以分别存在于多个工作表中而互不影响。具体操作步骤如下:

  1. 加载工作簿并获取第一个工作表。
  2. 通过 sheet.Names.Add("SalesData") 在第一个工作表上创建局部命名区域,指向 A2:D10。
  3. 通过 workbook.Worksheets.Add() 新增一个工作表,并在其上创建同名的局部命名区域,指向另一段区域。
  4. 分别读取两处命名区域的引用地址写回单元格。
  5. 保存工作簿。

下面是一个完整的代码示例,展示了在 React 中创建局部命名区域:

function App() {
  const createLocalNamedRange = async () => {
    // 获取 Spire.XLS WASM 模块
    const xlsModule = window.wasmModule?.spirexls;

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

    // 将中文字体载入 VFS
    await window.spire.FetchFileToVFS('simsun.ttc', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);

    // 将 Excel 文件载入 VFS
    const inputFileName = 'NamedRanges.xlsx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/static/data/`);

    // 加载工作簿
    const workbook = new xlsModule.Workbook();
    workbook.LoadFromFile(inputFileName);

    // 获取第一个工作表
    let sheet = workbook.Worksheets.get(0);

    // 在第一个工作表上创建局部命名区域
    let localRange = sheet.Names.Add("SalesData");
    localRange.RefersToRange = sheet.Range.get("A2:D10");

    // 新增一个工作表,并在其上创建同名的局部命名区域
    let sheet2 = workbook.Worksheets.Add("汇总");
    let localRange2 = sheet2.Names.Add("SalesData");
    localRange2.RefersToRange = sheet2.Range.get("A1:B5");

    // 分别读取两个工作表中同名命名区域的引用地址
    sheet.Range.get("F1").Text = "工作表1 的 SalesData";
    sheet.Range.get("F2").Text = localRange.RefersToRange.RangeAddress;
    sheet.Range.get("G1").Text = "工作表2 的 SalesData";
    sheet.Range.get("G2").Text = localRange2.RefersToRange.RangeAddress;

    // 自适应列宽
    sheet.AllocatedRange.AutoFitColumns();

    // 保存工作簿
    const outputFileName = 'LocalNamedRange.xlsx';
    workbook.SaveToFile(outputFileName);

    // 释放资源
    workbook.Dispose();

    // 从 VFS 读取结果文件,触发下载
    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
    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={createLocalNamedRange}>Start</button>
    </div>
  );
}

export default App;

运行后,创建局部命名区域的效果:

创建局部命名区域


常见问题

如何在公式中使用命名区域?

原因:命名区域真正的价值在于公式引用,把金额列定义为命名区域后,公式无需再写具体的区域地址,后续插入行时求和范围也会自动扩展。

解决:直接把命名区域的名称写进公式即可:

let namedRange = workbook.NameRanges.Add("SalesAmount");
namedRange.RefersToRange = sheet.Range.get("D2:D10");

// 在公式中引用命名区域
sheet.Range.get("F2").Formula = "=SUM(SalesAmount)";

如何读取工作簿中已有的命名区域?

原因:命名区域创建后会随工作簿一起保存,后续需要先把它读取出来,才能知道当前有哪些名称、各自指向哪一块区域。

解决:通过 NameRanges 集合遍历读取。先取总数,再按下标逐个读取名称与引用地址:

// 命名区域总数
let count = workbook.NameRanges.Count;

// 逐个读取名称与引用地址
for (let i = 0; i < count; i++) {
  let namedRange = workbook.NameRanges.get(i);
  sheet.Range.get(`F${i + 2}`).Text = namedRange.Name;
  sheet.Range.get(`G${i + 2}`).Text = namedRange.RefersToRange.RangeAddress;
}

以上遍历的是工作簿级命名区域;工作表级命名区域需通过 sheet.Names 读取,用法完全相同。


获取免费许可证

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

PDF 文档以“页”为基本单位,阅读、打印、归档都按页展开。实际业务里经常需要在已有 PDF 上调整页面:为合同补入一页签署说明、在报告末尾追加一张汇总页,或者把材料中多余的一页删掉。这类操作若借助本地软件或服务端重排,就要来回导出、上传,流程繁琐。如果能在浏览器端直接完成增删页面,文档无需离开用户设备,处理链路会短很多。

Spire.PDF for JavaScript 基于 WebAssembly 在浏览器端直接加载、修改与保存 PDF 文档,通过虚拟文件系统(VFS)管理输入输出文件,无需后端服务支持。

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

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


在 PDF 文档中添加页面

添加页面就是往 PdfPageCollection 集合里插一项。在指定位置插入使用 Pages.Insert(index),索引从 0 开始,插入点之后原有的页面会依次后移。这里把空白页放到第二页的位置,原有页面顺次后移,适合在文档中间补入新页。

function App() {
  const addPageToPdf = 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/`);

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

    // 在第二页位置插入一张空白页(索引从 0 开始,索引 1 对应第二页)
    doc.Pages.Insert(1);

    // 定义输出文件名并保存文档
    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>添加 PDF 页面</h1>
      <button onClick={addPageToPdf}>
        开始添加
      </button>
    </div>
  );
}

export default App;

在第二页位置插入空白页后的 PDF 文档

在第二页位置插入空白页后的 PDF 文档


在文档末尾添加空白页

Spire.PDF for JavaScript 还提供 Pages.Add() 方法,用于在文档末尾追加空白页。它默认采用 A4 页面尺寸,上下左右页边距均为 40 磅;需要时也可自定义页面尺寸与页边距。

function App() {
  const appendPageToPdf = 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/`);

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

    // 在文档末尾追加一页 A4 空白页,四边页边距均为 0
    doc.Pages.Add(pdfModule.PdfPageSize.A4(), new pdfModule.PdfMargins(0.0, 0.0));

    // 定义输出文件名并保存文档
    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={appendPageToPdf}>
        开始添加
      </button>
    </div>
  );
}

export default App;

在文档末尾追加一页 A4 空白页后的 PDF 文档

在文档末尾追加一页 A4 空白页后的 PDF 文档


在 PDF 文档中删除页面

删除页面同样围绕页面集合进行,Pages.RemoveAt(index) 按索引移除一页,索引从 0 开始,删除后其后页面的索引会整体前移一位。写删除逻辑前,最好先用 Pages.Count 确认当前页数,把索引控制在有效范围内。这里将样例文档的第二页移除,其余页面按原有顺序保留。

function App() {
  const deletePageFromPdf = 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/`);

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

    // 删除第二页(索引从 0 开始,索引 1 对应第二页)
    doc.Pages.RemoveAt(1);

    // 定义输出文件名并保存文档
    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>删除 PDF 页面</h1>
      <button onClick={deletePageFromPdf}>
        开始删除
      </button>
    </div>
  );
}

export default App;

删除第二页后的 PDF 文档

删除第二页后的 PDF 文档


常见问题

新增空白页时如何指定纸张大小与页边距

原因:Pages.Add 与 Pages.Insert 默认新建的是常规尺寸、默认页边距的空白页。若新页需要匹配特定的纸张规格或页边距,就要把尺寸与边距一并传入。

解决:纸张规格由 PdfPageSize 给出,如 PdfPageSize.A4()、PdfPageSize.A3();页边距用 PdfMargins 指定,两参数写法 new PdfMargins(0.0, 0.0) 表示上下与左右均为 0,需要分别控制四边时可传入具名参数:

// A4 纸张、四边页边距均为 0 的空白页
doc.Pages.Add(pdfModule.PdfPageSize.A4(), new pdfModule.PdfMargins(0.0, 0.0));

// 单独指定上下左右的页边距
doc.Pages.Add(pdfModule.PdfPageSize.A4(),
  new pdfModule.PdfMargins({ left: 40, top: 40, right: 40, bottom: 40 }));

删除页面时为什么会提示索引越界

原因:RemoveAt(index) 的索引从 0 开始,有效范围是 0 到 Pages.Count - 1。若事先没有确认页数,传入等于或超过 Count 的值就会越界。

解决:删除前用 Pages.Count 核对页数,把索引限制在有效范围内。例如删除最后一页时,索引应取 Count - 1:

let total = doc.Pages.Count;

if (total > 0) {
  // 删除最后一页
  doc.Pages.RemoveAt(total - 1);
}

连续增删多个页面时要注意什么

原因:每插入或删除一页,其后所有页面的索引都会随之变化。若按固定索引从前往后连续删除,很容易删错页面——删掉一页后,原本记录的后续索引已经整体前移。

解决:Insert、Add 与 RemoveAt 一次只处理一页,重复调用即可完成批量操作。批量删除时从后往前进行,可以避免删除过程中索引错位:

// 从后往前删除,索引不会因前一次删除而偏移
for (let i = doc.Pages.Count - 1; i >= 3; i--) {
  doc.Pages.RemoveAt(i);
}

获取免费许可证

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

在 PDF 文档中添加图形是很多业务场景中的常见需求:用线条和方框标注重点区域、给表格外的内容加一个醒目的边框、用填充色块区分不同板块,或者在图纸、报表上叠加饼形、椭圆等示意图形。如果每次都依赖设计软件手工绘制,不仅效率低下,也难以批量处理。借助 Spire.PDF for JavaScript 的绘图能力,可以在浏览器端直接向 PDF 页面写入各种形状,把标注和示意工作交给程序自动完成。

Spire.PDF for JavaScript 基于 WebAssembly 在浏览器端直接完成 PDF 的加载、编辑与保存,通过虚拟文件系统(VFS)管理输入输出文件,无需后端服务支持。在 PDF 上绘制形状的核心对象是页面的绘图画布 PdfPage.Canvas:它提供了 DrawLine、DrawPie、DrawRectangle、DrawEllipse 等方法。

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

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


在 PDF 页面中绘制线条

绘制线条时可以指定颜色与粗细,也能选择实线或虚线——虚实由 DashStyle 和 DashPattern 控制。

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

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

    // 创建 PDF 文档并添加一个空白页面
    let doc = new pdfModule.PdfDocument();
    let page = doc.Pages.Add();

    // 保存当前图形状态
    let state = page.Canvas.Save();

    // 创建红色画笔,用于绘制线条
    let pen = new pdfModule.PdfPen({
      pdfRGBColor: new pdfModule.PdfRGBColor({ color: pdfModule.Color.get_Red() }),
      width: 2,
    });

    // 线的起始点坐标与长度
    let x = 30.0;
    let y = 50.0;
    let width = 300.0;

    // 绘制一条实线
    page.Canvas.DrawLine({ pen: pen, x1: x, y1: y, x2: x + width, y2: y });

    // 设置虚线样式与虚线间隔
    pen.DashStyle = pdfModule.PdfDashStyle.Dash;
    pen.DashPattern = [3.0, 2.0];

    // 绘制一条虚线
    page.Canvas.DrawLine({ pen: pen, x1: x, y1: y + 60.0, x2: x + width, y2: y + 60.0 });

    // 恢复图形状态
    page.Canvas.Restore({ state: state });

    // 定义输出文件名并保存文档
    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>Draw Lines in PDF</h1>
      <button onClick={drawLines}>
        Draw
      </button>
    </div>
  );
}

export default App;

在 PDF 页面上绘制一条实线和一条虚线后的效果

在 PDF 页面上绘制一条实线和一条虚线后的效果


在 PDF 页面中绘制饼形

饼形用于表达占比,外接矩形决定位置和大小,startAngle 与 sweepAngle 决定扇形开口的角度。

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

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

    // 创建 PDF 文档并添加一个空白页面
    let doc = new pdfModule.PdfDocument();
    let page = doc.Pages.Add();

    // 保存当前图形状态
    let state = page.Canvas.Save();

    // 创建深红色画笔
    let pen = new pdfModule.PdfPen({
      pdfRGBColor: new pdfModule.PdfRGBColor({ color: pdfModule.Color.get_DarkRed() }),
      width: 2,
    });

    // 绘制第一个饼形
    page.Canvas.DrawPie({ pen: pen, x: 10.0, y: 30.0, width: 130.0, height: 130.0, startAngle: 360.0, sweepAngle: 300.0 });

    // 绘制第二个饼形
    page.Canvas.DrawPie({ pen: pen, x: 160.0, y: 30.0, width: 130.0, height: 130.0, startAngle: 360.0, sweepAngle: 330.0 });

    // 绘制第三个饼形
    page.Canvas.DrawPie({ pen: pen, x: 320.0, y: 30.0, width: 130.0, height: 130.0, startAngle: 360.0, sweepAngle: 360.0 });

    // 恢复图形状态
    page.Canvas.Restore({ state: state });

    // 定义输出文件名并保存文档
    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>Draw a Pie in PDF</h1>
      <button onClick={drawPie}>
        Draw
      </button>
    </div>
  );
}

export default App;

在 PDF 页面上绘制三个饼形后的效果

在 PDF 页面上绘制三个饼形后的效果


在 PDF 页面中绘制矩形

矩形既能只描边,也能填充。填充除纯色(PdfSolidBrush)外,还支持线性渐变(PdfLinearGradientBrush)与径向渐变(PdfRadialGradientBrush)。

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

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

    // 创建 PDF 文档并添加一个空白页面
    let doc = new pdfModule.PdfDocument();
    let page = doc.Pages.Add();

    // 保存当前图形状态
    let state = page.Canvas.Save();

    // 创建黑色画笔
    let pen = new pdfModule.PdfPen({
      pdfRGBColor: new pdfModule.PdfRGBColor({ color: pdfModule.Color.get_Black() }),
      width: 1,
    });

    // 用画笔绘制一个矩形的轮廓
    page.Canvas.DrawRectangle({
      pen: pen,
      rectangle: new pdfModule.RectangleF({
        location: new pdfModule.PointF(20.0, 30.0),
        size: new pdfModule.SizeF({ width: 150.0, height: 120.0 }),
      }),
    });

    // 创建一个线性渐变刷子对象
    let linearGradientBrush = new pdfModule.PdfLinearGradientBrush({
      point1: new pdfModule.PointF(200.0, 30.0),
      point2: new pdfModule.PointF(350.0, 150.0),
      color1: new pdfModule.PdfRGBColor({ color: pdfModule.Color.get_Green() }),
      color2: new pdfModule.PdfRGBColor({ color: pdfModule.Color.get_Red() }),
    });

    // 用线性渐变刷子绘制一个填充式矩形
    page.Canvas.DrawRectangle({
      brush: linearGradientBrush,
      rectangle: new pdfModule.RectangleF({
        location: new pdfModule.PointF(200.0, 30.0),
        size: new pdfModule.SizeF({ width: 150.0, height: 120.0 }),
      }),
    });

    // 创建一个径向渐变刷子对象
    let radialGradientBrush = new pdfModule.PdfRadialGradientBrush({
      centreStart: new pdfModule.PointF(380.0, 30.0),
      radiusStart: 150.0,
      centreEnd: new pdfModule.PointF(530.0, 150.0),
      radiusEnd: 150.0,
      colorStart: new pdfModule.PdfRGBColor({ color: pdfModule.Color.get_Orange() }),
      colorEnd: new pdfModule.PdfRGBColor({ color: pdfModule.Color.get_Blue() }),
    });

    // 用径向渐变刷子绘制一个填充式矩形
    page.Canvas.DrawRectangle({
      brush: radialGradientBrush,
      rectangle: new pdfModule.RectangleF({
        location: new pdfModule.PointF(380.0, 30.0),
        size: new pdfModule.SizeF({ width: 150.0, height: 120.0 }),
      }),
    });

    // 恢复图形状态
    page.Canvas.Restore({ state: state });

    // 定义输出文件名并保存文档
    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>Draw a Rectangle in PDF</h1>
      <button onClick={drawRectangle}>
        Draw
      </button>
    </div>
  );
}

export default App;

在 PDF 页面上绘制矩形轮廓与渐变填充矩形后的效果

在 PDF 页面上绘制矩形轮廓与渐变填充矩形后的效果


在 PDF 页面中绘制椭圆形

椭圆同样支持描边与填充,轮廓用 PdfPen,填充用 PdfSolidBrush,也可以直接取用 PdfPens 预设的画笔。

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

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

    // 创建 PDF 文档并添加一个空白页面
    let doc = new pdfModule.PdfDocument();
    let page = doc.Pages.Add();

    // 保存当前图形状态
    let state = page.Canvas.Save();

    // 创建 CadetBlue 画笔
    let pen = pdfModule.PdfPens.get_CadetBlue();

    // 绘制椭圆形状轮廓
    page.Canvas.DrawEllipse({ pen: pen, x: 50.0, y: 30.0, width: 120.0, height: 100.0 });

    // 创建填充刷子对象
    let brush = new pdfModule.PdfSolidBrush({
      pdfRGBColor: new pdfModule.PdfRGBColor({ color: pdfModule.Color.get_CadetBlue() }),
    });

    // 绘制填充的椭圆形状
    page.Canvas.DrawEllipse({ brush: brush, x: 180.0, y: 30.0, width: 120.0, height: 100.0 });

    // 恢复图形状态
    page.Canvas.Restore({ state: state });

    // 定义输出文件名并保存文档
    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>Draw an Ellipse in PDF</h1>
      <button onClick={drawEllipse}>
        Draw
      </button>
    </div>
  );
}

export default App;

在 PDF 页面上绘制椭圆轮廓与填充椭圆后的效果

在 PDF 页面上绘制椭圆轮廓与填充椭圆后的效果


常见问题

绘制的形状为什么出现在页面边缘或超出可视范围

原因:DrawLine、DrawPie、DrawRectangle、DrawEllipse 使用的坐标原点位于页面左下角,x 轴向右、y 轴向上,单位为点(point)。如果直接照搬屏幕坐标(原点在左上角),绘制出的形状位置就会与预期相反或超出页面。

解决:按页面左下角为原点来换算坐标。可以先读取页面尺寸再做布局,例如使用 PdfPage.Size 得到页面宽高,再据此计算形状的位置:

// 获取页面尺寸,按左下角为原点计算坐标
let size = page.Size;
let x = size.Width / 4;
let y = size.Height / 3;
page.Canvas.DrawRectangle({ pen: pen, x: x, y: y, width: 200, height: 120 });

绘制后页面原有内容发生变化或错位

原因:绘图时会修改画布的当前变换与图形状态。若在绘制前用 ScaleTransform、TranslateTransform 等改变了坐标系,或在绘制后没有还原状态,就会影响后续内容。

解决:成对使用 Canvas.Save 与 Canvas.Restore,把绘制操作包裹在两者之间,确保绘制完成后画布状态还原到绘制前的水平:

// 绘制前保存状态
let state = page.Canvas.Save();
// ……执行绘制……
// 绘制后恢复状态
page.Canvas.Restore({ state: state });

为什么保存的 PDF 里看不到绘制的形状

原因:形状是绘制在画布对象上的,如果绘制之后没有调用 doc.SaveToFile 把文档写回文件,或者输出的文件与读取下载的不是同一个文件名,就会导致看到的仍是原始内容。

解决:确认绘制完成后调用 doc.SaveToFile(outputFileName) 保存,并用同一个 outputFileName 从虚拟文件系统读取下载:

// 保存文档到指定文件名
doc.SaveToFile(outputFileName);
doc.Close();

// 用同一个文件名从 VFS 读取,触发下载
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);

获取免费许可证

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

Spire.XLS 16.9.2 现已发布。该版本新增支持 TRANSPOSE 公式,还修复了一些在转换 Excel 到 PDF 和导出数据时出现的问题。详情如下。

新功能:

问题修复:


下载Spire.XLS 16.9.2,请点击:

https://www.e-iceblue.cn/Downloads/Spire-XLS-NET.html

PDF 书签(Bookmark)记录了文档的大纲结构,一个书签下面还可以挂子书签,层层嵌套形成一棵树。读取这些信息有不少实际用途——把它导出成目录清单、根据标题生成站内导航,或者定位到某一页继续处理。要做到这些,程序需要能够遍历整棵书签树,逐个取出节点的内容。

Spire.PDF for JavaScript 基于 WebAssembly 在浏览器端直接处理 PDF 文档,通过虚拟文件系统(VFS)管理输入输出文件,无需后端服务支持。提取书签的核心入口是 PdfDocument.Bookmarks 属性,它返回一个 PdfBookmarkCollection 集合,其中的每个 PdfBookmark 对象都可以继续访问其子书签集合,从而构成完整的书签树。每个书签节点提供了 Title、DisplayStyle 等属性用于读取外观信息,还可以通过 Destination.Page 结合 PdfPageCollection.IndexOf 得到该书签指向的页码。

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

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


提取 PDF 文档的全部书签

PdfDocument.Bookmarks 返回的是顶层书签集合,而书签本身可以包含下一级子书签。要取出文档中的全部书签,需要编写一个递归函数:逐层遍历 PdfBookmarkCollection,读出每个节点的 Title(书签标题)与 DisplayStyle(文字样式),并按层级缩进记录,最终汇总成一份完整的大纲清单。

function App() {
  const extractAllBookmarks = 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/`);

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

    // 存放提取结果的字符串
    let content = 'PDF 文档中的全部书签:\r\n';

    // 递归遍历书签集合,按层级缩进记录标题与文字样式
    const collectBookmarks = (bookmarks, indent) => {
      for (let i = 0; i < bookmarks.Count; i++) {
        let bookmark = bookmarks.get_Item(i);

        // 记录当前书签的标题与文字样式
        content += indent + bookmark.Title + '(' + bookmark.DisplayStyle.toString() + ')\r\n';

        // 若存在子书签,则递归处理并增加缩进
        if (bookmark.Count > 0) {
          collectBookmarks(bookmark, indent + '    ');
        }
      }
    };

    // 从顶层书签开始提取
    collectBookmarks(doc.Bookmarks, '');

    // 将提取结果写入文件并触发下载
    const outputFileName = '提取书签结果.txt';
    window.dotnetRuntime.Module.FS.writeFile(outputFileName, content);
    doc.Close();

    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], { type: 'text/plain' });
    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>提取 PDF 文档的全部书签</h1>
      <button onClick={extractAllBookmarks}>
        开始提取
      </button>
    </div>
  );
}

export default App;

递归提取出的全部书签标题与文字样式清单

递归提取出的全部书签标题与文字样式清单


获取书签对应的页码

书签除了记录标题,还包含一个跳转目标。通过 PdfBookmark.Destination.Page 可以取得该书签指向的 PdfPage 对象,再借助 PdfPageCollection.IndexOf 得到它在文档中的索引。由于索引从 0 开始,加 1 即可得到阅读器中显示的页码。这一用法常用于把书签清单导出为“标题 — 页码”形式的目录。

function App() {
  const getBookmarkPageNumber = 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/`);

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

    // 逐一读取每个顶层书签指向的页码
    let content = '书签与对应页码:\r\n';
    for (let i = 0; i < doc.Bookmarks.Count; i++) {
      let bookmark = doc.Bookmarks.get_Item(i);

      // Destination.Page 给出书签指向的页面,IndexOf 得到其从 0 开始的索引
      let pageNumber = doc.Pages.IndexOf(bookmark.Destination.Page) + 1;

      content += bookmark.Title + ' —— 第 ' + pageNumber + ' 页\r\n';
    }

    // 将提取结果写入文件并触发下载
    const outputFileName = '书签页码.txt';
    window.dotnetRuntime.Module.FS.writeFile(outputFileName, content);
    doc.Close();

    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], { type: 'text/plain' });
    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={getBookmarkPageNumber}>
        开始提取
      </button>
    </div>
  );
}

export default App;

每个书签的标题及其指向的页码

每个书签的标题及其指向的页码


常见问题

为什么提取到的书签数量比阅读器大纲中看到的少

原因:doc.Bookmarks 只返回顶层书签集合,其 Count 也只统计当前这一层的节点数。嵌套在章节之下的子书签需要继续通过节点自身的集合访问,否则不会被计入。

解决:使用递归方式遍历整棵书签树,把每一层的节点都累加起来:

function countBookmarks(bookmarks) {
  let total = 0;
  for (let i = 0; i < bookmarks.Count; i++) {
    total += 1;
    // 递归累加子书签
    total += countBookmarks(bookmarks.get_Item(i));
  }
  return total;
}

const total = countBookmarks(doc.Bookmarks);

为什么提取到的 DisplayStyle 总是 Regular

原因:PdfBookmark.DisplayStyle 返回的是书签在大纲面板中显示的文字样式,只有当书签自身被显式设置为 Bold、Italic 等样式时,读到的才不是默认的 Regular。它反映的是书签的外观设置,与书签标题在页面正文中用的字体无关。

解决:按枚举值原样记录即可;若只需区分是否加粗或斜体,可与 PdfTextStyle 的取值逐一比较:

let style = 'Regular';
if (bookmark.DisplayStyle === pdfModule.PdfTextStyle.Bold) {
  style = 'Bold';
} else if (bookmark.DisplayStyle === pdfModule.PdfTextStyle.Italic) {
  style = 'Italic';
}

为什么用 Destination.Page 得到的页码和阅读器显示的不一致

原因:PdfPageCollection.IndexOf 返回的是页面在集合中的索引,从 0 开始计数,而阅读器中显示的页码从 1 开始,因此直接使用索引会相差 1。

解决:在索引基础上加 1 即与阅读器显示一致:

// 索引从 0 开始,加 1 得到阅读器中的页码
let pageNumber = doc.Pages.IndexOf(bookmark.Destination.Page) + 1;

此外,如果书签指向的是文档中不存在的页面(例如目标页已被删除),Destination 可能为空,取值前应先做判空处理:

if (bookmark.Destination && bookmark.Destination.Page) {
  let pageNumber = doc.Pages.IndexOf(bookmark.Destination.Page) + 1;
}

获取免费许可证

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

在 Word 里改内容不难,改完之后把目录、页码、页眉页脚重新对齐却往往更费事。一份文档只要经历几轮增删、章节顺序调整,或者从别的模板迁移过来,原来的目录条目、页码和页眉里的章节名就很容易跟正文对不上——目录点进去翻错页,页码从某一节开始接不上,页眉还挂着上一版的章节标题。手工逐项核对既耗时又容易漏项,文档越长越难保证一致。

对比传统SDK API处理

传统 Spire.Office for .NET API Spire.Agent.Office
驱动方式 编写代码逐项处理:遍历段落判层级→更新目录域→重排页码→改页眉页脚,每一步都要代码控制 用自然语言说明要重建哪些结构,AI 自动完成
代码量 目录域、分节页码、页眉字段等需分别维护一套处理逻辑 仅需配置代码 + 1 条自然语言指令
标题层级识别 依赖样式名或大纲级别硬判定,样式不规范时容易误判 AI 结合语义与样式综合判断标题层级
分节与字段处理 分节符、页码起始、PAGE/STYLEREF 等字段需逐个手工设置 自动识别分节与字段引用关系并成组更新
维护性 文档模板或结构变化后需改代码重新发版 重建范围与规则可用自然语言随时调整

本文介绍如何使用 Spire.Agent.Office Word AI 能力完成文档结构重建,覆盖从目录到页码、页眉页脚的两类典型问题:先让 AI 扫描标题层级与分节信息、按正文实际标题重新生成目录,再刷新页码并更新页眉页脚中的动态字段,使目录、页码、页眉页脚与正文保持一致。

有关产品安装和 SpireToken 配置,请参考 在 .NET 项目中集成 Spire.Agent.Office。以下示例默认已安装 Spire.Agent.Office 并完成 SpireToken 配置。


目录结构重建

目录对不上,多半是正文改过之后目录没跟着更新,或者原先的目录本就是手工敲的静态文字。结构重建的核心思路是:加载已有文档,让 AI 扫描正文的各级标题、判断层级关系并核对分节位置,按正文实际标题重新生成一份带页码的目录域,让目录条目、层级与正文一一对应,同时只调整标题样式、不动正文内容。

using Spire.Agent.Office.AI;
using Spire.Agent.Office.Extensions;
using Spire.Doc;

// 待重建结构的文档
string inputPath = "E:\\Input\\XX项目实施方案.docx";
// 保存路径
string savePath = "E:\\Output\\XX项目实施方案-结构重建.docx";
// SpireToken Key
string key = "**********************";
// 自然语言指令
string instruction =
    "请重建当前文档的目录结构:" +
    "1. 扫描正文标题,识别各级标题的层级关系,统一标题样式(一级标题用标题1,二级标题用标题2,依次类推);" +
    "2. 检查分节符位置,确保章节划分与标题层级一致;" +
    "3. 删除原有目录,在正文前重新生成目录域,目录包含各级标题并显示对应页码,与正文实际标题、层级完全一致;" +
    "4. 只调整标题样式与目录,正文内容保持原样。" +
    "最终保存输出DOCX格式";

// 调用Word文档处理函数
AIResult result = ExecuteDemoWord(instruction, inputPath, savePath, key, null);

// 执行Word文档AI处理
static AIResult ExecuteDemoWord(string instruction, string inputPath, string savePath, string key, string[] attachmentPaths)
{
    // 创建AIOptions选项配置对象
    AIOptions options = new AIOptions();
    // 设置SpireToken Key
    options.SpireToken = key;

    // 使用Document对象处理Word文档
    using (Document doc = new Document())
    {
        // 加载待重建结构的文档
        if (!string.IsNullOrEmpty(inputPath) && File.Exists(inputPath))
        {
            doc.LoadFromFile(inputPath);
        }
        // 创建AI文档处理器
        AIDocumentProcessor processor = doc.AI(options);

        // 执行AI指令
        return processor.ExecuteInstruction(doc, instruction, savePath, attachmentPaths);
    }
}

重建后的目录 重建后的目录

重建后的目录条目、层级与正文标题一一对应。对结构改动较大的文档,直接重生成目录比手工增删目录项更省事,也更不容易漏改。


页码与页眉页脚刷新

目录重建后,页码与页眉页脚也需要跟着对齐。页码错位通常出在分节设置上,页眉里的章节名或总页数显示的是字段的旧值。这一步的核心思路是:让 AI 刷新全文页码并按分节设置起始值与续排方式,同时更新页眉页脚中的动态字段(如章节名、总页数、日期),使这些字段的取值与正文当前内容一致。

using Spire.Agent.Office.AI;
using Spire.Agent.Office.Extensions;
using Spire.Doc;

// 待刷新页码的文档(可接上一节重建后的文档)
string inputPath = "E:\\Input\\XX项目实施方案-结构重建.docx";
// 保存路径
string savePath = "E:\\Output\\XX项目实施方案-定稿.docx";
// SpireToken Key
string key = "**********************";
// 自然语言指令
string instruction =
    "请刷新当前文档的页码并更新页眉页脚中的动态字段:" +
    "1. 重新计算并刷新全文页码,正文页码从第1页起连续编号;" +
    "2. 按分节设置页码,封面与目录不编页码,正文单独起页并重新起始;" +
    "3. 更新页眉中的章节名(取值自对应级别标题)与总页数等动态字段,使其与正文当前内容一致;" +
    "4. 页脚页码统一为“第 X 页 共 Y 页”格式。" +
    "只更新上述结构信息,不修改正文内容,最终保存输出DOCX格式";

// 调用Word文档处理函数
AIResult result = ExecuteDemoWord(instruction, inputPath, savePath, key, null);

// 执行Word文档AI处理
static AIResult ExecuteDemoWord(string instruction, string inputPath, string savePath, string key, string[] attachmentPaths)
{
    // 创建AIOptions选项配置对象
    AIOptions options = new AIOptions();
    // 设置SpireToken Key
    options.SpireToken = key;

    // 使用Document对象处理Word文档
    using (Document doc = new Document())
    {
        // 加载待刷新的文档
        if (!string.IsNullOrEmpty(inputPath) && File.Exists(inputPath))
        {
            doc.LoadFromFile(inputPath);
        }
        // 创建AI文档处理器
        AIDocumentProcessor processor = doc.AI(options);

        // 执行AI指令
        return processor.ExecuteInstruction(doc, instruction, savePath, attachmentPaths);
    }
}

刷新页码与页眉页脚后的文档 刷新后的页码与页眉页脚

刷新后,正文页码连续、分节起始正确,页眉中的章节名与总页数同正文保持一致。对成批处理的文档,可用同一套指令统一页码规则与页眉页脚格式,省去逐份打开、逐项核对的时间。


常见问题

重建后目录仍显示旧条目或出现多余项

原因:原文档的目录是静态文字而非目录域,或标题样式不统一,导致识别出的层级出现偏差。

解决:在指令中明确要求“删除原有目录后按正文标题重新生成目录域”,并说明标题的识别依据(按样式名或大纲级别),减少误判。

页码从某一节开始对不上或重复

原因:分节符的页码起始值与续排方式与需求不符,手工设置时容易遗漏某一节。

解决:在指令中逐条写明各节的页码要求,如“封面与目录不编页码、正文从第1页起、各节连续编号”,由 AI 按节统一设置。

页眉里的章节名或总页数没有变化

原因:章节名与总页数多为 STYLEREF、NUMPAGES 等动态字段,未刷新时仍显示缓存值。

解决:要求“更新页眉页脚中所有动态字段的取值”,并说明字段来源,如章节名取自对应级别标题,总页数取全文页数。

结构重建时正文格式被一并改动

原因:未限定操作范围,AI 在统一标题样式时顺带调整了正文的字体与段落格式。

解决:在指令中明确“只调整标题样式与目录、页码、页眉页脚等结构信息,保持正文字体与段落格式不变”。


获取SpireToken Key

  • 联系 该Email地址已收到反垃圾邮件插件保护。要显示它您需要在浏览器中启用JavaScript。 或访问 https://www.e-iceblue.com/TemLicense.html 获得试用/商业 API 密钥

在代码中配置:

AIOptions options = new AIOptions();
options.SpireToken = key;