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

Spire.Cloud 纯前端文档控件

在文档分发与协作场景中,控制读者"能对文档做什么"往往比控制"谁能打开文档"更重要——把合同模板发给客户时,只希望对方填写空白项而不改动既定条款;把定稿发给团队时,只允许批注而不允许直接修改正文。这类需求通过限制编辑实现,与设置打开密码是两个不同的概念。Spire.Doc for JavaScript 基于 WebAssembly 在浏览器端直接处理 Word 文档,通过虚拟文件系统(VFS)管理字体和文件资源,无需后端服务支持。

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

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


指定保护类型保护文档

Word 的限制编辑共有五种保护类型,分别对应不同的可编辑范围:NoProtection(不限制)、AllowOnlyComments(仅允许批注)、AllowOnlyFormFields(仅允许填写表单域)、AllowOnlyReading(仅允许阅读)、AllowOnlyRevisions(仅允许修订)。调用 Protect 方法传入 ProtectionType 枚举值与密码,即可按需为整篇文档施加限制。

指定保护类型的核心流程分为三个阶段:首先通过 FetchFileToVFS 将字体文件和目标 Word 文档载入 WASM 虚拟文件系统;然后实例化 Document 加载文件,调用 Protect 方法指定保护类型与解除限制所需的密码;最后保存文档并从 VFS 读取生成的文件,封装为 Blob 后触发浏览器下载。

function App() {
  const protectWithSpecifiedType = async () => {
    const docModule = window.wasmModule?.spiredoc;
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }

    const inputFileName = 'Template.docx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/data/`);

    // 加载文档
    const doc = new docModule.Document();
    doc.LoadFromFile(inputFileName);

    // 以"仅允许阅读"类型保护文档,密码用于解除限制
    doc.Protect({ type: docModule.ProtectionType.AllowOnlyReading, password: "123456" });

    // 定义输出文件名并保存
    const outputFileName = "SpecifiedProtectionType.docx";
    doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
    doc.Dispose();

    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
    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={protectWithSpecifiedType}>开始</button>
    </div>
  );
}
export default App;

以 AllowOnlyReading 类型保护后,文档内容仅可查看,编辑功能区被限制。

以 AllowOnlyReading 类型保护后的文档效果


只锁定指定节

整篇文档统一保护并不总是合适。在合同、报价单这类模板中,通常只有少数几处需要填写,其余条款必须保持锁定。此时可以先把整篇文档按 AllowOnlyFormFields 保护,再将允许编辑的那一节通过 ProtectForm 属性单独放开,实现按节粒度的权限控制。

只锁定指定节的核心流程分为三个阶段:首先通过 FetchFileToVFS 将字体文件载入 WASM 虚拟文件系统;然后实例化 Document,用 AddSection 创建多个节并写入内容,先调用 Protect 将整篇文档按"仅允许填写表单域"保护,再把需要放开的节的 ProtectForm 属性设为 false;最后保存文档并从 VFS 读取生成的文件,封装为 Blob 后触发浏览器下载。

function App() {
  const lockSpecifiedSections = async () => {
    const docModule = window.wasmModule?.spiredoc;
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }

    // 新建文档,并添加两个节
    const doc = new docModule.Document();
    let s1 = doc.AddSection();
    let s2 = doc.AddSection();

    // 分别向两个节写入内容
    s1.AddParagraph().AppendText("Spire.Doc 演示,第1部分");
    s2.AddParagraph().AppendText("Spire.Doc 演示,第2部分");

    // 整篇文档按"仅允许填写表单域"保护
    doc.Protect({ type: docModule.ProtectionType.AllowOnlyFormFields, password: "123" });

    // 单独放开第 2 节,使其可以编辑
    s2.ProtectForm = false;

    // 定义输出文件名并保存
    const outputFileName = 'LockSpecifiedSections.docx';
    doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
    doc.Dispose();

    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
    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>锁定Word文档的指定部分</h1>
      <button onClick={lockSpecifiedSections}>开始</button>
    </div>
  );
}
export default App;

第 2 节被放开后,文档中仅第 1 节保留编辑限制。

只锁定指定节后的文档效果


常见问题

设置 ProtectForm = false 后指定节仍无法编辑

原因:ProtectForm 仅在文档以 AllowOnlyFormFields(仅允许填写表单域)类型保护时生效。若文档使用的是 AllowOnlyReading 等其他保护类型,单独放开某一节不会起作用。

解决:确认 Protect 传入的保护类型与放开节的操作相匹配:

doc.Protect({ type: wasmModule.ProtectionType.AllowOnlyFormFields, password: "123" });
s2.ProtectForm = false;

保护后的文档仍可被选中复制

原因:所有保护类型限制的都是"编辑"行为,而非"读取"行为。AllowOnlyReading 只阻止修改正文,不影响选中、复制与搜索;若要连内容读取一并限制,应使用打开密码而非限制编辑。

解决:根据实际目的选择手段——需要防止内容被拿走时使用文档加密,仅在需要防止内容被改动时使用限制编辑:

doc.Protect({ type: wasmModule.ProtectionType.AllowOnlyReading, password: "123456" });

获取免费许可证

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

在合同、表单与公文模板这类文档中,往往只希望填写人修改其中少数几处内容(例如签署信息、项目名称、验收结论),其余条款必须保持原样。为文档设置可编辑区域,就能把"允许改哪里"精确限定下来,其余内容一律只读。Spire.Doc for JavaScript 基于 WebAssembly 在浏览器端直接完成这一处理,通过虚拟文件系统(VFS)管理字体与文件资源,无需后端服务支持。

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

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


设置可编辑区域

设置可编辑区域的核心流程分为三个阶段:首先通过 FetchFileToVFS 将字体文件和目标 Word 文档载入 WASM 虚拟文件系统;然后实例化 Document 加载文件,调用 Protect 将整篇文档设为只读,再创建一对 id 相同的 PermissionStart 与 PermissionEnd 标记,把指定段落标记为可编辑区域;最后保存文档并从 VFS 读取生成的文件,封装为 Blob 后触发浏览器下载。

function App() {
  const SetEditableRange = async () => {
    const docModule = window.wasmModule?.spiredoc;
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }

    // 将输入文档载入 VFS
    const inputFileName = "SetEditableRange.docx";
    await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);

    // 创建文档对象并加载文档
    const doc = new docModule.Document();
    doc.LoadFromFile(inputFileName);

    // 保护整篇文档:除可编辑区域外,其余内容一律只读
    doc.Protect({ type: docModule.ProtectionType.AllowOnlyReading, password: "password" });

    // 创建权限标记,id 相同的 start 与 end 构成一个可编辑区域
    const start = new docModule.PermissionStart(doc, "testID");
    const end = new docModule.PermissionEnd(doc, "testID");

    // 将标记插入第一个段落:起点置于段首,终点追加到段尾
    doc.Sections.get_Item(0).Paragraphs.get_Item(0).ChildObjects.Insert(0, start);
    doc.Sections.get_Item(0).Paragraphs.get_Item(0).ChildObjects.Add(end);

    // 保存文档
    const outputFileName = "设置编辑区域.docx";
    doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
    doc.Dispose();

    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
    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>设置Word文档内容的编辑区域</h1>
      <button onClick={SetEditableRange}>
        开始
      </button>
    </div>
  );
}
export default App;

示例文档中用浅色底纹标出需要填写的位置(仅为视觉提示,与可编辑区域的设置无关)。设置可编辑区域后,只有带底纹的段落可以修改,其余条款在 Word 中处于只读状态。

设置可编辑区域后的文档,浅色底纹段落为可编辑区域


删除可编辑区域

删除可编辑区域只需一次遍历:依次访问文档的每个节、每个段落,在段落的 ChildObjects 集合中查找 PermissionStart 与 PermissionEnd 对象,命中即从集合中移除。

这里有一个容易踩坑的细节:ChildObjects.Remove 执行后集合会立即缩短,后续元素的下标整体前移。因此移除时不能再递增下标,否则每删掉一个标记就会跳过紧随其后的一个,造成漏删。

function App() {
  const RemoveEditableRange = async () => {
    const docModule = window.wasmModule?.spiredoc;
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }

      // 将输入文档载入 VFS
      const inputFileName = "RemoveEditableRange.docx";
      await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);

      // 创建文档对象并加载文档
      const doc = new docModule.Document();
      doc.LoadFromFile(inputFileName);

      // 遍历所有节与段落,删除权限标记
      for (let i = 0; i < doc.Sections.Count; i++) {
        const section = doc.Sections.get_Item(i);
        for (let j = 0; j < section.Body.Paragraphs.Count; j++) {
          const paragraph = section.Body.Paragraphs.get_Item(j);

          // 命中即移除,移除后集合缩短、下标不递增
          for (let k = 0; k < paragraph.ChildObjects.Count;) {
            const obj = paragraph.ChildObjects.get_Item(k);
            if (obj instanceof docModule.PermissionStart || obj instanceof docModule.PermissionEnd) {
              paragraph.ChildObjects.Remove(obj);
            } else {
              k++;
            }
          }
        }
      }

      // 保存文档
      const outputFileName = "移除编辑区域.docx";
      doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });

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

    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
    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>移除Word文档中的编辑区域</h1>
      <button onClick={RemoveEditableRange}>
        开始
      </button>
    </div>
  );
}
export default App;

删除标记只影响可编辑区域的划分,文档的文字内容与格式不会发生任何变化。

删除可编辑区域标记后的文档,内容与格式保持不变


常见问题

设置了可编辑区域,但可编辑范围内的内容仍然无法编辑

原因:权限标记必须与文档的编辑限制配合才会生效。只插入 PermissionStart 与 PermissionEnd 而不调用 Protect,文档并未进入保护状态,标记不会产生任何效果;此外两个标记的 id 必须完全一致,Word 才会把它们识别为同一个可编辑区域。

解决:先启用编辑限制,再用同一个 id 创建成对的标记:

// 先启用保护,标记才会有意义
document.Protect({ type: wasmModule.ProtectionType.AllowOnlyReading, password: "password" });

// start 与 end 必须使用相同的 id
const start = new wasmModule.PermissionStart(document, "testID");
const end = new wasmModule.PermissionEnd(document, "testID");

删除可编辑区域时出现漏删

原因:ChildObjects.Remove 会让集合中后续元素的下标整体前移。如果在 for 循环中一边递增下标一边删除,每删除一个对象就会跳过紧随其后的一个,文档中残留的标记数量越多,漏删越明显。

解决:改为"命中即删除、下标不递增",或者先收集待删除对象再倒序遍历:

for (let k = 0; k < paragraph.ChildObjects.Count;) {
  const obj = paragraph.ChildObjects.get_Item(k);
  if (obj instanceof wasmModule.PermissionStart || obj instanceof wasmModule.PermissionEnd) {
    paragraph.ChildObjects.Remove(obj);
    // 此处不递增 k,继续检查当前下标上的新对象
  } else {
    k++;
  }
}

删除标记后文档仍然处于只读状态

原因:PermissionStart 与 PermissionEnd 只是"允许编辑哪些区域"的标记,删除它们并不会关闭文档的编辑限制。文档的保护依然生效,此时整篇文档都不可编辑。

解决:如果确认不再需要保护,可在删除标记之后再调用 Unprotect;若文档设置了密码,请传入当初设置保护时的密码:

document.Unprotect("password");

获取免费许可证

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

在 Word 文档中处理图片是日常办公开发中最常见的需求之一,无论是为合同模板添加公司 Logo、清理文档中残留的占位图片,还是批量替换报告里的印章与图标,图片的动态操作都能让文档内容随业务数据同步更新。Spire.Doc for JavaScript 基于 WebAssembly 在浏览器端直接完成 Word 文档的图片处理,通过虚拟文件系统(VFS)管理字体、文档与图片资源,无需后端服务支持。

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

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


在Word文档中添加图片

添加图片的核心流程分为三个阶段:首先通过 FetchFileToVFS 将字体文件、目标 Word 文档和待插入的图片文件载入 WASM 虚拟文件系统;然后实例化 Document 加载文件,获取目标节后创建 DocPicture 对象,通过 LoadImage 载入图片并设置位置、尺寸与文字环绕方式,再用 ChildObjects.Insert 将图片插入到指定段落的指定位置;最后将文档保存并从 VFS 读取生成的文件,封装为 Blob 后触发浏览器下载。

function App() {
  const insertImage = async () => {
    const docModule = window.wasmModule?.spiredoc;
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }
    // 将输入文档载入 VFS
    const inputFileName = "BlankTemplate.docx";
    await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);

    // 将待插入的图片文件载入 VFS
    const inputImgFileName = "Word.png";
    await window.spire.FetchFileToVFS(inputImgFileName, "", `${process.env.PUBLIC_URL}static/data/`);

    // 加载文档
    let doc = new docModule.Document();
    doc.LoadFromFile(inputFileName);

    // 获取第一节
    let section = doc.Sections.get_Item(0);

    // 添加标题段落
    let paragraph = section.AddParagraph();
    paragraph.AppendText("该示例演示了如何将图片插入到文档中。");
    paragraph.ApplyStyle({ builtinStyle: docModule.BuiltinStyle.Heading2 });

    // 新增一个用于承载图片的段落
    paragraph = section.AddParagraph();
    paragraph.AppendText("这是一张图片。");

    // 创建图片对象并载入图片文件
    let picture = new docModule.DocPicture(doc);
    picture.LoadImage({ imgFile: inputImgFileName });

    // 设置图片的位置
    picture.HorizontalPosition = 50.0;
    picture.VerticalPosition = 60.0;

    // 设置图片的尺寸
    picture.Width = 200;
    picture.Height = 200;

    // 设置图片的文字环绕方式
    picture.TextWrappingStyle = docModule.TextWrappingStyle.Through;

    // 将图片插入到段落的开头(索引 0)
    paragraph.ChildObjects.Insert(0, picture);

    // 保存文档
    const outputFileName = "插入图片.docx";
    doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx });
    // 释放资源
    doc.Dispose();

    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
    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>插入图片到Word文档</h1>
      <button onClick={insertImage}>
        生成
      </button>
    </div>
  );
}
export default App;

上述代码通过 DocPicture 载入图片,设置其绝对位置为 (50, 60)、尺寸为 200×200,并采用四周环绕方式,最后插入到第二个段落的开头,生成的 Word 文档效果如下:

图片通过 DocPicture 插入到指定段落后的 Word 输出


删除Word文档中的图片

删除图片的核心流程同样分为三个阶段:首先通过 FetchFileToVFS 将字体文件和待处理的 Word 文档载入 WASM 虚拟文件系统;然后实例化 Document 加载文件,逐层遍历文档中的节、段落以及段落内的子对象,通过 DocumentObjectType.Picture 判断对象是否为图片,若是则调用 ChildObjects.Remove 将其从段落中移除;最后保存文档并从 VFS 读取生成的文件,封装为 Blob 后触发浏览器下载。

function App() {
  const RemoveImage = async () => {
    const docModule = window.wasmModule?.spiredoc;
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }
   // 将输入文档载入 VFS
      const inputFileName = "ImageTemplate.docx";
      await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}/static/data/`);

      // 加载文档
      let doc = new docModule.Document();
      doc.LoadFromFile(inputFileName);

      let removedCount = 0;

      // 遍历所有节与段落,删除段落中的图片
      for (let i = 0; i < doc.Sections.Count; i++) {
        let sec = doc.Sections.get_Item(i);
        for (let j = 0; j < sec.Paragraphs.Count; j++) {
          let para = sec.Paragraphs.get_Item(j);

          // 收集段落中的所有图片对象
          let pictures = [];
          for (let k = 0; k < para.ChildObjects.Count; k++) {
            let docObj = para.ChildObjects.get_Item(k);
            if (docObj.DocumentObjectType == docModule.DocumentObjectType.Picture) {
              pictures.push(docObj);
            }
          }

          // 逐个移除段落中的图片
          for (let m = 0; m < pictures.length; m++) {
            para.ChildObjects.Remove(pictures[m]);
            removedCount++;
          }
        }
      }

      // 保存文档
      const outputFileName = "删除图片.docx";
      doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx });
    // 释放资源
    doc.Dispose();

    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
    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>从Word文档中删除图片</h1>
      <button onClick={RemoveImage}>
        生成
      </button>
    </div>
  );
}
export default App;

上述代码遍历文档结构并移除所有图片对象,段落中的文字内容与格式保持不变,生成的 Word 文档效果如下:

删除段落中的图片后的 Word 输出


替换Word文档中的图片

替换图片可以看作是删除与添加的组合操作,核心流程分为三个阶段:首先通过 FetchFileToVFS 将字体文件、目标 Word 文档和新图片文件载入 WASM 虚拟文件系统;然后遍历节与段落,定位其中的图片对象并记录其在段落中的索引、原始尺寸与文字环绕方式,调用 ChildObjects.Remove 移除旧图片后,创建新的 DocPicture 并以相同参数在原索引位置插入;最后保存文档并从 VFS 读取生成的文件,封装为 Blob 后触发浏览器下载。

function App() {
  const ReplaceImage = async () => {
    const docModule = window.wasmModule?.spiredoc;
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }

      // 将输入文档载入 VFS
      const inputFileName = "ImageTemplate.docx";
      await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}/static/data/`);

      // 将替换后的新图片载入 VFS
      const inputImgFileName = "NewLogo.png";
      await window.spire.FetchFileToVFS(inputImgFileName, "", `${process.env.PUBLIC_URL}/static/data/`);

      // 加载文档
      let doc = new docModule.Document();
      doc.LoadFromFile(inputFileName);

      // 遍历所有节与段落,将段落中的图片替换为新图片
      for (let i = 0; i < doc.Sections.Count; i++) {
        let sec = doc.Sections.get_Item(i);
        for (let j = 0; j < sec.Paragraphs.Count; j++) {
          let para = sec.Paragraphs.get_Item(j);

          // 收集段落中的图片对象及其在 ChildObjects 中的索引
          let pictures = [];
          for (let k = 0; k < para.ChildObjects.Count; k++) {
            let docObj = para.ChildObjects.get_Item(k);
            if (docObj.DocumentObjectType == docModule.DocumentObjectType.Picture) {
              pictures.push({ index: k, picture: docObj });
            }
          }

          // 倒序遍历,避免移除对象后索引发生偏移
          for (let m = pictures.length - 1; m >= 0; m--) {
            let index = pictures[m].index;
            let picture = pictures[m].picture;

            // 记录原图片的尺寸与文字环绕方式
            let width = picture.Width;
            let height = picture.Height;
            let wrappingStyle = picture.TextWrappingStyle;

            // 移除原图片
            para.ChildObjects.Remove(picture);

            // 创建新图片并沿用原图片的尺寸与环绕方式
            let newPicture = new docModule.DocPicture(doc);
            newPicture.LoadImage({ imgFile: inputImgFileName });
            newPicture.Width = width;
            newPicture.Height = height;
            newPicture.TextWrappingStyle = wrappingStyle;

            // 在原索引位置插入新图片
            para.ChildObjects.Insert(index, newPicture);
          }
        }
      }

      // 保存文档
      const outputFileName = "更新图片.docx";
      doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx });
    // 释放资源
    doc.Dispose();

    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
    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>替换Word文档中的图片</h1>
      <button onClick={ReplaceImage}>
        生成
      </button>
    </div>
  );
}
export default App;

上述代码在移除原图片的同时,将新图片插入到原图片所在的索引位置,并沿用其尺寸与环绕方式,因此替换后的文档版式与替换前完全一致,生成的 Word 文档效果如下:

文档中的图片被替换为新图片后的 Word 输出


常见问题

图片插入后不显示,或提示找不到图片文件

原因:图片文件未载入 WASM 虚拟文件系统,或 FetchFileToVFS 的加载路径与实际文件位置不一致,导致 LoadImage 无法从 VFS 中读取图片数据。

解决:插入图片前先通过 FetchFileToVFS 将图片载入 VFS,并确保目标文件名与 LoadImage 传入的文件名保持一致:

await window.spire.FetchFileToVFS(
  'Word.png', '', `${process.env.PUBLIC_URL}static/data/`
);
let picture = new wasmModule.DocPicture(doc);
picture.LoadImage({ imgFile: 'Word.png' });

删除或替换图片后仍有残留,或图片位置发生偏移

原因:ChildObjects.Remove 会使后续子对象的索引整体前移,若在遍历过程中一边读取 ChildObjects.Count 一边移除,会跳过部分图片;替换时若直接使用移除后的新索引插入,也会导致图片插入到错误的位置。

解决:先一次性收集段落中的图片对象及其原始索引,再倒序遍历处理,插入新图片时沿用记录下来的原始索引:

// 先收集图片及其索引
pictures.push({ index: k, picture: docObj });

// 再倒序遍历,移除后以原索引插回新图片
for (let m = pictures.length - 1; m >= 0; m--) {
  para.ChildObjects.Remove(pictures[m].picture);
  para.ChildObjects.Insert(pictures[m].index, newPicture);
}

获取免费许可证

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

在 Word 文档中处理形状是日常办公开发中最常见的需求之一,无论是为合同模板加盖一个圆角矩形的签章框、用流程图形状组标注审批步骤,还是清理文档中残留的装饰形状、把旧模板里形状的配色统一成新的品牌色,形状的动态操作都能让文档内容随业务数据同步更新。Spire.Doc for JavaScript 基于 WebAssembly 在浏览器端直接完成 Word 文档的形状处理,通过虚拟文件系统(VFS)管理字体、文档与图片资源,无需后端服务支持。

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

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


在Word文档中添加形状

添加形状的核心流程分为三个阶段:首先通过 FetchFileToVFS 将字体文件载入 WASM 虚拟文件系统;然后实例化 Document 并依次添加节与段落,调用 Paragraph.AppendShape 在段落中插入指定类型、指定尺寸的形状,再通过 HorizontalOrigin/VerticalOrigin 把形状锚定到页面,配合 HorizontalPosition/VerticalPosition 设置其绝对坐标;最后将文档保存并从 VFS 读取生成的文件,封装为 Blob 后触发浏览器下载。

function App() {
  const appendShape = async () => {

    const docModule = window.wasmModule?.spiredoc;

    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }

      // 新建文档
    const doc = new docModule.Document();

      // 添加一节
    let sec = doc.AddSection();

      // 添加一个用于承载形状的段落
    let paragraph = sec.AddParagraph();

    let x = 60, y = 40, lineCount = 0;
    for (let i = 1; i < 20; i++) {
      if (lineCount > 0 && lineCount % 8 == 0) {
        // 每页排满 8 行后分页,并重置起始坐标
        paragraph.AppendBreak(docModule.BreakType.PageBreak);
        x = 60;
        y = 40;
        lineCount = 0;
      }

        // 添加形状,并设置其尺寸
      let shape = paragraph.AppendShape(50, 50, docModule.ShapeType.fromValue(i));

      // 以页面为参照系设置形状的绝对位置
      shape.HorizontalOrigin = docModule.HorizontalOrigin.Page;
      shape.HorizontalPosition = x;
      shape.VerticalOrigin = docModule.VerticalOrigin.Page;
      shape.VerticalPosition = y + 50;

      // 计算下一个形状的坐标
      x = x + shape.Width + 50;
      if (i > 0 && i % 5 == 0) {
        y = y + shape.Height + 120;
        lineCount++;
        x = 60;
        }
    }

    // 保存文档
    const outputFileName = "添加形状.docx";
    doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx });
    // 释放资源
    doc.Dispose();

    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
    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>添加形状到Word文档</h1>
      <button onClick={appendShape}>
        开始
      </button>
    </div>
  );
}
export default App;

上述代码循环插入 19 种不同的预设形状,每个形状尺寸为 50×50,通过 ShapeType.fromValue(i) 逐一取出形状类型,并以页面为坐标原点按行列排布,每行 5 个,排满 8 行后通过 AppendBreak 自动分页,生成的 Word 文档效果如下:

形状通过 AppendShape 插入到段落后的 Word 输出

当需要一次插入多个相互关联的形状时(例如用矩形、平行四边形与箭头拼出一张流程图),可以改用 Paragraph.AppendShapeGroup 先创建一个形状组,再通过 ChildObjects.Add 把文本框、箭头等子形状加入组内统一排布:形状组内的坐标以组自身为参照,因此需要用 Width / 1000.0 与 Height / 1000.0 算出缩放比例,再把子形状的目标坐标除以该比例赋值给 HorizontalPosition/VerticalPosition;文本框通过 new wasmModule.TextBox(doc) 创建后用 SetShapeType 指定外形,其 Format.LineColor 与普通形状的 StrokeColor 一样可用于设置描边色。


删除Word文档中的形状

删除形状的核心流程分为三个阶段:首先通过 FetchFileToVFS 将字体文件和待处理的 Word 文档载入 WASM 虚拟文件系统;然后实例化 Document 加载文件,逐层遍历文档中的节与段落,通过 DocumentObjectType 判断段落内的子对象是否为形状——文档中的普通形状与文本框统一以 Shape 类型存在,形状组为 ShapeGroup,而在内存中通过 new wasmModule.TextBox(doc) 新建、尚未保存的文本框单独为 TextBox,三种类型都需要一并判断;确认后将对象收集起来,调用 ChildObjects.Remove 逐个移出段落;最后保存文档并从 VFS 读取生成的文件,封装为 Blob 后触发浏览器下载。

function App() {
  const removeShape = async () => {
    const docModule = window.wasmModule?.spiredoc;
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }
    const inputFileName = 'ShapeTemplate.docx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}static/data/`);

   // 加载文档
    let doc = new docModule.Document();
    doc.LoadFromFile(inputFileName);

    let removedCount = 0;

    // 遍历所有节与段落,删除段落中的形状
    for (let i = 0; i < doc.Sections.Count; i++) {
      let sec = doc.Sections.get_Item(i);
      for (let j = 0; j < sec.Paragraphs.Count; j++) {
        let para = sec.Paragraphs.get_Item(j);

        // 一次性收集段落中的形状:普通形状与文本框为 Shape,形状组为 ShapeGroup
        let shapes = [];
        for (let k = 0; k < para.ChildObjects.Count; k++) {
          let docObj = para.ChildObjects.get_Item(k);
          let objType = docObj.DocumentObjectType;
          if (objType == docModule.DocumentObjectType.Shape
            || objType == docModule.DocumentObjectType.ShapeGroup
            || objType == docModule.DocumentObjectType.TextBox) {
              shapes.push(docObj);
    }
        }

        // 逐个移除段落中的形状
        for (let m = 0; m < shapes.length; m++) {
         para.ChildObjects.Remove(shapes[m]);
         removedCount++;
        }
      }
    }

    // 保存文档
    const outputFileName = "删除形状.docx";
    doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx });

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

    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
    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>从Word文档中删除形状</h1>
      <button onClick={removeShape}>
        开始
      </button>
    </div>
  );
}
export default App;

上述代码遍历文档结构并移除页面上的所有形状,段落中的文字内容与格式保持不变,生成的 Word 文档效果如下:

删除段落中的形状后的 Word 输出


修改Word文档中的形状

修改形状的核心流程分为三个阶段:首先通过 FetchFileToVFS 将字体文件和待处理的 Word 文档载入 WASM 虚拟文件系统;然后实例化 Document 加载文件,遍历节与段落定位其中的形状对象,对普通形状与文本框直接设置 FillColor、StrokeColor 更换配色,设置 Rotation、Width、Height 调整旋转角度与尺寸,对形状组则继续下钻到其 ChildObjects 逐一修改组内子形状;最后保存文档并从 VFS 读取生成的文件,封装为 Blob 后触发浏览器下载。

function App() {
  const modifyShape = async () => {
    const docModule = window.wasmModule?.spiredoc;
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }
    const inputFileName = 'ShapeTemplate.docx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}static/data/`);

    // 加载文档
    let doc = new docModule.Document();
    doc.LoadFromFile(inputFileName);

    // 遍历所有节与段落,修改段落中的形状
    for (let i = 0; i < doc.Sections.Count; i++) {
      let sec = doc.Sections.get_Item(i);
      for (let j = 0; j < sec.Paragraphs.Count; j++) {
        let para = sec.Paragraphs.get_Item(j);
        for (let k = 0; k < para.ChildObjects.Count; k++) {
          let docObj = para.ChildObjects.get_Item(k);
          let objType = docObj.DocumentObjectType;

          // 修改普通形状与文本框的填充色、轮廓色、旋转角度与尺寸
          if (objType == docModule.DocumentObjectType.Shape) {
            docObj.FillColor = docModule.Color.get_Orange();
            docObj.StrokeColor = docModule.Color.get_Red();
            docObj.Rotation = 15;
            docObj.Width = docObj.Width * 1.2;
            docObj.Height = docObj.Height * 1.2;
          }

          // 修改形状组:下钻到组内,逐一修改子形状的轮廓色
          if (objType == docModule.DocumentObjectType.ShapeGroup) {
            for (let n = 0; n < docObj.ChildObjects.Count; n++) {
              let child = docObj.ChildObjects.get_Item(n);
              child.StrokeColor = docModule.Color.get_Purple();
            }
          }
        }
      }
    }

    // 保存文档
    const outputFileName = "修改形状.docx";
    doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx });

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

    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
    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>修改Word文档中已有的形状</h1>
      <button onClick={modifyShape}>
        开始
      </button>
    </div>
  );
}
export default App;

上述代码在保留形状原有位置与文字的前提下,把文档中的形状统一改为橙色填充、红色轮廓,并放大 120%、旋转 15 度,形状组内的子形状也一并换成了新的轮廓色,生成的 Word 文档效果如下:

形状的填充色、轮廓色与尺寸被修改后的 Word 输出


常见问题

形状添加后位置错乱,或跑到页面之外

原因:HorizontalPosition/VerticalPosition 的坐标含义取决于参照系(Origin)。若未显式设置 HorizontalOrigin/VerticalOrigin,坐标默认以段落或栏为参照,会随段落缩进、页面边距的变化而偏移,导致形状偏离预期位置。

解决:先用 HorizontalOrigin/VerticalOrigin 指定参照系,再设置具体的坐标值:

// 以页面为参照系定位形状
shape.HorizontalOrigin = wasmModule.HorizontalOrigin.Page;
shape.HorizontalPosition = x;
shape.VerticalOrigin = wasmModule.VerticalOrigin.Page;
shape.VerticalPosition = y + 50;

删除形状时漏掉了部分对象

原因:形状在 Spire.Doc 中并非只有一种 DocumentObjectType:普通形状与文本框统一为 Shape,形状组为 ShapeGroup,而在内存中新建、尚未保存的文本框为 TextBox。只判断 Shape 会漏掉形状组与尚未保存的文本框;此外 ChildObjects.Remove 会使后续子对象的索引整体前移,若在遍历过程中一边读取 ChildObjects.Count 一边移除,还会跳过部分对象。

解决:先把三种类型一次性收集到数组中,再对数组逐个处理;修改形状组内的子形状时,需下钻到其 ChildObjects 单独遍历:

// 先收集三种类型的形状对象
if (objType == wasmModule.DocumentObjectType.Shape
  || objType == wasmModule.DocumentObjectType.ShapeGroup
  || objType == wasmModule.DocumentObjectType.TextBox) {
  shapes.push(docObj);
}

// 再统一移除
for (let m = 0; m < shapes.length; m++) {
  para.ChildObjects.Remove(shapes[m]);
}

获取免费许可证

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

在招投标场景中,技术标应答是每个投标项目的必经环节,也是最容易出差错的环节之一。一份招标文件动辄几十上百页,"技术需求"章节里的条款少则数十条、多则数百条,投标方必须逐条应答、逐条说明偏离情况,任何一条漏答都可能构成废标。同时应答内容必须与企业自己的产品参数、检测报告和业绩案例一一对应——写得太空泛会被评委判定为不响应,而为了凑齐"完全响应"去虚构参数或业绩,一旦中标反而会带来更严重的履约与合规风险。传统做法依赖投标专员逐条阅读、逐条查找资料、逐行填表,一份技术标往往要耗费数天,且不同项目、不同人员的应答深度与口径差异很大。

对比传统SDK API处理

传统 Spire.Office for .NET API Spire.Agent.Office
驱动方式 编写代码逐条处理:加载招标文件→正则拆分条款→关键词匹配企业资料→逐行填充应答表,每步需代码控制 自然语言描述应答要求,AI 自动拆解条款并逐条匹配应答
代码量 需大量代码维护条款拆分规则、资料匹配逻辑与表格填充 仅需配置代码 + 1 条自然语言指令
条款拆分 依赖条款编号与 ★▲ 符号作正则锚点,跨页表格、多级编号容易错拆漏拆 AI 结合语义与编号综合识别,层级与跨页不丢条目
资料匹配 只能做关键词命中,同一项能力的两种表述匹配不上 语义级匹配,产品手册可按能力描述对应到具体条款
偏离判定 判定规则需硬编码,优于/等同/缺少难以规则化 按指令给出的判定口径逐条判定,证据不足时主动降级
维护性 招标文件格式变化或应答口径调整需改代码重新发版 判定口径与章节结构可用自然语言随时调整

本文介绍如何使用 Spire.Agent.Office Word AI 能力,把招标文件与企业技术资料一并交给 AI,一次生成包含技术应答表与技术方案正文的完整技术标应答文件,覆盖单标段与多标段批量两种用法。

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


技术标应答文件生成

技术标应答文件生成,是指把招标文件中的技术需求条款逐条拆解出来,对照企业自有技术资料编写应答内容、判定偏离情况,并组织成一份可直接提交的技术标文档的过程。其核心思路是:将招标文件与企业技术资料作为附件一并传给 AI,由 AI 按招标文件原有编号逐条生成"技术应答表",再依据应答结果续写技术方案正文,一篇之内产出完整的技术标应答文件。

这一能力解决的是三个传统方案绕不开的难点:

  • 语义级对应:招标文件写"支持国产化信创环境部署",企业资料里写的是"已完成麒麟、统信操作系统适配"——表述完全不同但指的是同一件事,只有理解语义才能对应上,关键词匹配方案做不到。
  • 不漏项:应答条款数量与招标文件的技术需求严格一致,不合并、不遗漏,从源头规避废标风险。
  • 不臆造:企业资料中没有依据的条款一律判定为"部分响应"并注明缺失事项,而不是为凑齐"完全响应"去补写。

单标段:一份招标文件,产出一份完整技术标

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

// 招标文件与企业技术资料(数据源文件)
string[] attachmentPaths = new string[] {
    @"E:\Input\XX项目招标文件.docx", 
    @"E:\Input\企业技术资料.docx" 
};
// 保存路径
string savePath = @"E:\Output\XX项目技术标应答文件.docx";
// SpireToken Key
string key = "**************************";
// 自然语言指令
string instruction =
    "基于附件的招标文件与企业技术资料编写技术标应答文件。" +
    "按招标文件原有编号逐条生成'技术应答表',列为:序号 | 招标要求 | 我方应答 | 偏离情况 | 说明;" +
    "偏离情况仅取'完全响应/部分响应/不响应';" +
    "应答须依据企业技术资料、引用对应的产品型号/参数/业绩;" +
    "资料中无依据的条款一律判'部分响应'并在说明列注明缺失,不得臆造;" +
    "条款与招标文件技术需求逐条对应、不得遗漏;" +
    "应答表后续写技术方案正文,含项目理解、技术方案、实施计划、质量保障、售后服务五章。";

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


// 执行Word文档AI处理
static AIResult ExecuteDemoWord(string instruction, string savePath, string key, string[] attachmentPaths)
{
    // 创建AIOptions选项配置对象
    AIOptions options = new AIOptions();
    // 设置单次调用超时预算(毫秒)
    options.TimeoutMs = 3600000;
    // 设置SpireToken Key
    options.SpireToken = key;

    // 使用Document对象处理Word文档
    using (Document doc = new Document())
    {
        // 创建AI文档处理器
        AIDocumentProcessor processor = doc.AI(options);

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

AI 生成的技术标应答文件(技术应答表 + 技术方案正文): AI生成的技术标应答文件

生成的应答文件中,技术应答表逐条对应招标文件的原有编号,每条都写明了应答内容与偏离情况,能对应的条款直接引用具体产品型号与业绩案例;技术方案正文则基于应答表展开,形成项目理解、技术方案、实施计划、质量保障、售后服务五个章节。投标专员只需核对偏离判定是否与实际情况一致、补充缺少的资料,即可进入内部会签。

多标段:多份招标文件批量产出,口径统一

同一项目划分为多个标段时,各标段的招标文件技术需求不同,但使用的企业技术资料是同一份,此时更适合一次批量产出。做法是把 options.WorkDir 设为输出目录、savePath 传 null,再把各标段的招标文件一并作为附件传入——一次调用即可产出多份文件,文件名由 AI 按标段拟定。因为各标段共用同一份资料、同一条指令,判定口径天然就统一了。

需要留意的是单次调用有超时预算(默认约 300 秒),多标段批量建议抬高 options.TimeoutMs,否则调用会在预算耗尽时中断。

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

// 各标段的招标文件(数据源文件)
string[] lotFiles = new string[] {
    @"E:\Input\标段一-技术需求.docx",   // 标段一招标文件
    @"E:\Input\标段二-技术需求.docx"    // 标段二招标文件
};
// 各标段共用同一份企业技术资料
string materialPath = @"E:\Input\企业技术资料.docx";
// 输出目录
string OutDir = @"E:\Output";
// SpireToken Key
string key = "**************************";
// 自然语言指令
string instruction =
    "基于附件的招标文件与企业技术资料编写技术标应答文件。" +
    "按该标段招标文件原有编号逐条生成'技术应答表',列为:序号 | 招标要求 | 我方应答 | 偏离情况 | 说明;" +
    "偏离情况仅取'完全响应/部分响应/不响应',判定口径:" +
    "完全满足或优于→完全响应,有差异但不影响使用→部分响应,无法满足→不响应;" +
    "应答须依据企业资料、引用对应的产品型号/参数/业绩;" +
    "无依据的条款一律判'部分响应'并在说明列注明缺失,不得臆造;" +
    "条款与该标段技术需求逐条对应、不得遗漏;" +
    "应答表后续写技术方案正文,含项目理解、技术方案、实施计划、质量保障、售后服务五章。" +
    "附件含两个标段的招标文件,每标段各产出一份、共 2 份;" +
    "文件名以 output 开头并标明标段(如 output-标段一-技术标应答文件.docx);" ;

// 附件:两份标段招标文件 + 共用的企业技术资料
string[] attachmentPaths = new string[] { lotFiles[0], lotFiles[1], materialPath };

// 调用Word文档处理函数
AIResult result = ExecuteDemoWordMultiLot(instruction, null, key, OutDir, attachmentPaths);

// 执行Word文档AI处理
static AIResult ExecuteDemoWordMultiLot(string instruction, string? savePath, string key, string output, string[] attachmentPaths)
{
    // 创建AIOptions选项配置对象
    AIOptions options = new AIOptions();
    // 设置工作目录为输出目录
    options.WorkDir = output;
    // 设置单次调用超时预算(毫秒)
    options.TimeoutMs = 3600000;
    // 设置SpireToken Key
    options.SpireToken = key;

    // 使用Document对象处理Word文档
    using (Document doc = new Document())
    {
        // 创建AI文档处理器
        AIDocumentProcessor processor = doc.AI(options);

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

AI 批量生成的各标段技术标应答文件(每标段一份,命名对应标段): AI批量生成的各标段技术标应答文件

一次调用产出多份独立应答文件,每份以标段命名,便于分包提交。由于所有标段共用同一套偏离判定口径与技术资料,不同标段的应答尺度保持一致,不会出现同一项能力在一个标段判"完全响应"、在另一个标段判"部分响应"的情况。


常见问题

应答条款有漏项,数量与招标文件对不上

原因:招标文件的技术需求常以多级编号、跨页表格甚至图片形式编排,AI 拆分时可能把相邻条款合并,或漏掉续页的条目。

解决:在指令中明确"逐条提取,不得合并、不得概括",并要求"应答条款数量须与招标文件技术需求一致"。对条款特别多的项目,可先让 AI 单独输出一份条款清单(序号 + 原文),人工核对条数无误后再生成应答,形成"先拆解、再应答"的两步流程。

应答内容空泛,与企业实际能力对不上

原因:只传了招标文件,没有传企业技术资料,AI 只能写出通用性表述。

解决:将产品手册、检测报告、资质证书、同类项目业绩作为附件一并传入,并在指令中要求"能对应的条款须引用具体产品型号、技术参数或业绩案例"。

出现虚构的参数、证书或业绩

原因:招标要求与企业资料存在差距时,AI 倾向于补写内容来填满应答表。

解决:指令中明确"严禁为凑齐完全响应而虚构参数、证书或业绩,无法确认的留空并标注待人工确认",同时把"资料中无对应内容的条款一律判定为部分响应"写成硬性规则。

偏离情况全部被判定为"完全响应"

原因:未给出偏离判定口径,AI 只能自行把握尺度,通常偏宽松。

解决:在指令中写清判定标准,例如"招标要求完全满足或优于→完全响应;存在差异但不影响使用→部分响应;无法满足→不响应"。需要更严格时,可进一步要求"仅当企业资料中有明确参数或案例支撑时才可判定完全响应"。

应答表版式错乱、长条款挤成一团

原因:应答表列数多、单元格内容长,AI 排版时容易出现列顺序变动或行高异常。

解决:在指令中固定列名与列顺序(序号 | 招标要求 | 我方应答 | 偏离情况 | 说明),并按需补上版式要求(表格居中、表头加粗、正文小四宋体、1.5 倍行距);对超长条款可要求"招标要求列保留原句,超 80 字截断加…"。


获取SpireToken Key

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

在代码中配置:

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

产品目录、检查清单、操作步骤、条款摘要,这类内容在 PDF 里用列表呈现最清楚。手工在编辑器里一条条敲还算可行,但条目一旦要跟着数据走——比如把一份分类表、一份待办清单导出成 PDF,手工排版就跟不上了:条目数量、编号顺序、缩进层级都得随数据生成。

本文介绍用 Spire.PDF for JavaScript 实现无序列表、有序列表与多级列表,其中无序列表分内置符号与图片符号两种。它基于 WebAssembly 在浏览器端直接创建与保存 PDF 文档,全过程在本地完成,通过虚拟文件系统(VFS)读写文件,无需后端配合。

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

列表类型不同,用到的类和标记对象也不同,对照下表选择:

列表类型 列表类 标记配置 常用取值
无序列表(内置符号) PdfList PdfMarker + PdfUnorderedMarkerStyle Disk、Square、Circle、Asterisk
无序列表(图片符号) PdfList PdfMarker 的 image 参数(样式自动变为 CustomImage) 任意图片,按文字行高缩放
有序列表(编号) PdfSortedList PdfOrderedMarker + PdfNumberStyle(Suffix 改编号后缀,StartNumber 改起始编号) Numeric、LowerLatin、UpperLatin、LowerRoman、UpperRoman
多级列表(嵌套) PdfList / PdfSortedList + 列表项的 SubList 各级列表各自的 Marker 与 Indent 一级与子级可各自取不同的符号或编号

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


在 PDF 页面中创建无序列表

Spire.PDF for JavaScript 提供 PdfList 创建列表,通过 PdfUnorderedMarkerStyle 枚举类型切换符号形状。

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

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

    // 将中文字体载入 VFS,供列表文字使用
    await window.spire.FetchFileToVFS('ARIAL UNICODE MS.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);

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

    // 列表字体与条目内容
    const font = new pdfModule.PdfTrueTypeFont({ fontFile: '/Library/Fonts/ARIAL UNICODE MS.TTF', size: 12 });
    const items = ['果汁饮料', '调味品', '糖果点心', '乳制品', '谷物与麦片', '肉类与禽类', '果蔬', '海产品'];

    // 第一份列表:逐项添加,项目符号用 Square
    const list = new pdfModule.PdfList({ font: font });
    for (const item of items) {
      list.Items.Add(item);
    }
    list.Marker = new pdfModule.PdfMarker({ style: pdfModule.PdfUnorderedMarkerStyle.Square });
    // Brush 同时作用于项目符号与列表文字
    list.Brush = new pdfModule.PdfSolidBrush({ pdfRGBColor: new pdfModule.PdfRGBColor({ color: pdfModule.Color.get_Navy() }) });
    list.Indent = 10;
    list.TextIndent = 6;
    const first = list.Draw({ page: page, x: 0, y: 40 });

    // 第二份列表接着第一份的底部绘制,项目符号改用 Circle
    const list2 = new pdfModule.PdfList({ font: font });
    for (const item of items) {
      list2.Items.Add(item);
    }
    list2.Marker = new pdfModule.PdfMarker({ style: pdfModule.PdfUnorderedMarkerStyle.Circle });
    list2.Brush = pdfModule.PdfBrushes.get_Black();
    list2.Indent = 10;
    list2.TextIndent = 6;
    list2.Draw({ page: page, x: 0, y: first.Bounds.Bottom + 20 });

    // 保存并从 VFS 读回,触发下载
    const outputFileName = '项目符号列表.pdf';
    doc.SaveToFile(outputFileName);
    doc.Close();

    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={createBulletLists}>
        开始创建
      </button>
    </div>
  );
}

export default App;

同一份内容用 Square 与 Circle 两种项目符号创建的无序列表:

同一份内容用 Square 与 Circle 两种项目符号创建的无序列表


在 PDF 页面中用图片创建无序列表

Spire.PDF for JavaScript 还提供 PdfImage 读取图片,把它交给 PdfMarker 即可作为项目符号,符号样式自动变为 CustomImage。

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

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

    // 将中文字体与作项目符号的图片载入 VFS
    await window.spire.FetchFileToVFS('ARIAL UNICODE MS.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
    await window.spire.FetchFileToVFS('logo.png', '', `${process.env.PUBLIC_URL}/data/`);

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

    // 列表字体与条目内容
    const font = new pdfModule.PdfTrueTypeFont({ fontFile: '/Library/Fonts/ARIAL UNICODE MS.TTF', size: 12 });
    const items = ['果汁饮料', '调味品', '糖果点心', '乳制品', '谷物与麦片', '肉类与禽类', '果蔬', '海产品'];

    // 读取图片交给 PdfMarker 作符号,样式自动变为 CustomImage
    const image = pdfModule.PdfImage.FromFile('logo.png');
    const marker = new pdfModule.PdfMarker({ image: image });

    // 逐项添加条目并套用图片符号
    const list = new pdfModule.PdfList({ font: font });
    for (const item of items) {
      list.Items.Add(item);
    }
    list.Marker = marker;
    list.Brush = pdfModule.PdfBrushes.get_Black();
    list.Indent = 10;
    list.TextIndent = 6;
    list.Draw({ page: page, x: 0, y: 40 });

    // 保存并从 VFS 读回,触发下载
    const outputFileName = '图片符号列表.pdf';
    doc.SaveToFile(outputFileName);
    doc.Close();

    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={createImageBulletList}>
        开始创建
      </button>
    </div>
  );
}

export default App;

以 logo.png 作项目符号创建的无序列表:

以 logo.png 作项目符号创建的无序列表


在 PDF 页面中创建有序列表

有序列表由 PdfSortedList 创建,编号随条目自动递增,编号形式由 PdfOrderedMarker 上的 PdfNumberStyle 枚举类型决定。

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

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

    // 将中文字体载入 VFS,供列表文字使用
    await window.spire.FetchFileToVFS('ARIAL UNICODE MS.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);

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

    // 列表字体与条目内容
    const font = new pdfModule.PdfTrueTypeFont({ fontFile: '/Library/Fonts/ARIAL UNICODE MS.TTF', size: 12 });
    const items = ['果汁饮料', '调味品', '糖果点心', '乳制品'];

    // 第一份列表:阿拉伯数字编号,编号后缀默认是 "."
    const list = new pdfModule.PdfSortedList({
      marker: new pdfModule.PdfOrderedMarker({ style: pdfModule.PdfNumberStyle.Numeric, font: font }),
    });
    list.Font = font;
    for (const item of items) {
      list.Items.Add(item);
    }
    list.Indent = 12;
    list.TextIndent = 6;
    list.Brush = pdfModule.PdfBrushes.get_Black();
    const first = list.Draw({ page: page, x: 0, y: 40 });

    // 第二份列表:大写罗马数字编号,编号后缀改为 "、"
    const marker = new pdfModule.PdfOrderedMarker({ style: pdfModule.PdfNumberStyle.UpperRoman, font: font });
    marker.Suffix = '、';
    const list2 = new pdfModule.PdfSortedList({ marker: marker });
    list2.Font = font;
    for (const item of items) {
      list2.Items.Add(item);
    }
    list2.Indent = 12;
    list2.TextIndent = 6;
    list2.Brush = pdfModule.PdfBrushes.get_Black();
    list2.Draw({ page: page, x: 0, y: first.Bounds.Bottom + 20 });

    // 保存并从 VFS 读回,触发下载
    const outputFileName = '编号列表.pdf';
    doc.SaveToFile(outputFileName);
    doc.Close();

    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={createOrderedLists}>
        开始创建
      </button>
    </div>
  );
}

export default App;

阿拉伯数字与大写罗马数字两种编号方式创建的有序列表

阿拉伯数字与大写罗马数字两种编号方式创建的有序列表


在 PDF 页面中创建多级列表

多级列表靠条目上的 SubList 属性实现,把子列表挂到某个条目上就多出一级,缩进由子列表自己的 Indent 决定。

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

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

    // 将中文字体载入 VFS,供列表文字使用
    await window.spire.FetchFileToVFS('ARIAL UNICODE MS.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);

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

    // 列表字体与分组数据
    const font = new pdfModule.PdfTrueTypeFont({ fontFile: '/Library/Fonts/ARIAL UNICODE MS.TTF', size: 12 });
    const groups = [
      { title: '饮料与乳品', children: ['果汁饮料', '乳制品'] },
      { title: '生鲜与谷物', children: ['谷物与麦片', '果蔬'] },
      { title: '肉类与海鲜', children: ['肉类与禽类', '海产品'] },
    ];

    // 一级列表:阿拉伯数字编号
    const root = new pdfModule.PdfSortedList({
      marker: new pdfModule.PdfOrderedMarker({ style: pdfModule.PdfNumberStyle.Numeric, font: font }),
    });
    root.Font = font;
    root.Indent = 10;
    root.TextIndent = 6;
    root.Brush = pdfModule.PdfBrushes.get_Black();

    // 每个一级条目下挂一个子列表,子列表用 Disk 项目符号并缩进 18 磅
    for (const group of groups) {
      const item = root.Items.Add(group.title);
      const sub = new pdfModule.PdfList({ font: font });
      for (const child of group.children) {
        sub.Items.Add(child);
      }
      sub.Marker = new pdfModule.PdfMarker({ style: pdfModule.PdfUnorderedMarkerStyle.Disk });
      sub.Indent = 18;
      sub.TextIndent = 6;
      sub.Brush = pdfModule.PdfBrushes.get_Black();
      item.SubList = sub;
    }

    // 整份多级列表从页面顶端开始绘制
    root.Draw({ page: page, x: 0, y: 40 });

    // 保存并从 VFS 读回,触发下载
    const outputFileName = '多级列表.pdf';
    doc.SaveToFile(outputFileName);
    doc.Close();

    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={createMultilevelList}>
        开始创建
      </button>
    </div>
  );
}

export default App;

编号一级条目与项目符号子列表组成的两级列表

编号一级条目与项目符号子列表组成的两级列表


常见问题

列表项的文字没有显示出来

原因:列表条目用的是 Font 属性上的字体。没有设置它,或者字体文件还没有读进虚拟文件系统时,页面上只会留下项目符号,文字(尤其是中文)不会出现。

解决:先把字体文件载入 /Library/Fonts/,再把字体对象交给列表的 Font:

// 字体文件读入虚拟文件系统
await window.spire.FetchFileToVFS('ARIAL UNICODE MS.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);

// 用该文件创建字体,在构造列表时传入(构造后再赋给 list.Font 效果相同)
const font = new pdfModule.PdfTrueTypeFont({ fontFile: '/Library/Fonts/ARIAL UNICODE MS.TTF', size: 12 });
const list = new pdfModule.PdfList({ font: font });

创建列表时报 "Ambiguous call"

原因:PdfList、PdfSortedList、PdfMarker 都有多个重载,位置传参无法判断该走哪一个,抛出 Ambiguous call: arguments (object) match multiple overloads.,并在报错里列出可选的键名。list.Draw(page, x, y) 这种位置传参同样会被拒绝。

解决:改用对象表示法,把参数名显式写出来:

// 报错写法:new pdfModule.PdfList(font)
const list = new pdfModule.PdfList({ font: font });
const sortedList = new pdfModule.PdfSortedList({ marker: marker });
const marker2 = new pdfModule.PdfMarker({ style: pdfModule.PdfUnorderedMarkerStyle.Disk });

// 绘制同理:list.Draw(page, 0, 40) 报错,改成
list.Draw({ page: page, x: 0, y: 40 });

用图片作项目符号时报 "IO_FileNotFound_FileName"

原因:PdfImage.FromFile 读的是虚拟文件系统里的路径,图片没有先用 FetchFileToVFS 读进去时就会抛 IO_FileNotFound_FileName——图片存在磁盘上不算数。

解决:先把图片读进虚拟文件系统,再用同一个文件名读取:

// 图片读入虚拟文件系统
await window.spire.FetchFileToVFS('logo.png', '', `${process.env.PUBLIC_URL}/data/`);

// 用同一个文件名读取
const image = pdfModule.PdfImage.FromFile('logo.png');
const marker = new pdfModule.PdfMarker({ image: image });

获取免费许可证

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

在人事与财务部门,工资条的发放是一项每月必做、却又格外琐碎的工作。工资表通常是一张按行排列的 Excel 表格——每一行对应一名员工,列则是基本工资、绩效奖金、加班费、社保扣除、公积金、个税和实发工资等项目。但发到员工手里时,每个人只应该看到属于自己的那一行。于是"一张表"必须先被"拆成 N 份",再逐份发送出去。

传统做法是在表格里逐行复制粘贴:新建一个文件、把表头粘一遍、再把某位员工的那一行粘进去、另存为 PDF,然后重命名、再循环下一位。几十人的团队就要重复几十遍,上百人时不仅耗时,还极易出现漏发、错发或把别人的薪酬发错对象的情况——而薪酬恰恰是最不能出错的信息。

Spire.Agent.Office 的 Excel AI 能力可以直接用自然语言描述拆分规则,AI 智能体理解后自动完成两类工作:把一张工资表拆成每人一份的独立工资条 PDF,以及让工资条套用统一的版式模板并按人加密,形成可以直接分发的文件,并顺带产出一份可随时查阅的分发台账。

对比传统 SDK API 处理

传统 Spire.Office for .NET API Spire.Agent.Office 处理
驱动方式 编写循环遍历数据行 + 新建工作簿 + 复制表头与数据 + 导出 PDF 的完整代码,控制每一步 用自然语言描述拆分与分发要求,AI 理解后自动编排执行路径
代码量 批量拆分与导出发送通常需要 200-400 行 C# 代码(含数据读取、行循环、工作表创建、样式复制、分页设置、PDF 导出、文件命名、加密参数等) 约 10 行调用代码 + 1 条自然语言指令
版式处理 需手动复制表头样式、设置列宽与打印区域,否则每份工资条都会错版 AI 自动识别并沿用原表头的边框、对齐与列宽
需求变更 改动拆分规则/命名方式/加密方式 → 改代码 → 编译 → 重新部署 修改指令,即刻生效

本文介绍如何使用 Spire.Agent.Office 将 Excel 工资表批量拆分为独立工资条 PDF。全文通过两个案例展示"直接拆分工资表"与"套用模板并加密分发"两种典型用法:

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


案例一:工资表批量拆分为单人工资条 PDF

这是最直接的场景:手上只有一张原始工资表,没有额外的模板文件。表格首行是表头,其后每一行是一名员工的当月薪酬数据。需要做的是把这张表按行拆开,为每位员工生成一份只包含本人信息的工资条,并以"工号_姓名"命名保存,便于后续按人分发或归档。

手工完成这件事的痛点在于"重复":人数越多,复制粘贴的次数越多,越到后面越容易串行——把上一位的实发工资留在下一位的文件里,是很难被发现却后果严重的一类错误。

以下示例使用 Spire.Agent.Office 智能体,通过自然语言指令读取工资表中的全部员工数据,逐行拆分生成独立的工资条并导出为 PDF:

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

// Excel 处理相关配置
string inputPath = @"E:\payroll.xlsx";  // 待拆分的工资表文件路径
string savePath = null;  // 结果文档路径(此处为null,将使用下面设置的输出文件夹路径)
string OutDir = @"E:\output";  // 输出目录(拆分后的工资条 PDF 将保存到此目录)
string key = "**************************";  // SpireToken Key
string instruction =
"读取工资表中的员工薪酬数据,为每位员工拆分生成一份独立的工资条:" +
  "1. 首行为表头,其后每一行对应一名员工,逐行提取工号、姓名、部门与各薪酬项目;" +
  "2. 每份工资条上方显示标题'工资条'与工资发放月份,并列明该员工的工号、姓名、部门,并按源表列顺序逐项列出基本工资、绩效奖金、加班费、社保扣除、公积金、个税与实发工资;" +
  "3. 每位员工生成一份独立的 PDF 文件,以'工号_姓名'命名保存到输出目录;" +
  "4. 保留原表头的边框与列宽设置,金额列右对齐并保留两位小数;" +
  "5. 页面尺寸贴合工资条内容,边距紧凑、高度随内容自适应,不要留下大片空白;" +
  "最终输出保存为PDF文件";

// 调用Excel文档处理函数
AIResult result = ExecuteDemoExcel(instruction, inputPath, savePath, key, OutDir);


// 执行Excel文档AI处理
static AIResult ExecuteDemoExcel(string instruction, string inputPath, string savePath, string key, string output)
{
    // 创建AIOptions选项配置对象
    AIOptions options = new AIOptions();
    options.WorkDir = output;  // 设置工作目录为输出目录
    options.SpireToken = key;  // 设置SpireToken Key

    // 使用Workbook对象处理Excel文档
    using (Workbook workbook = new Workbook())
    {
        // 从文件加载Excel工资表
        if (!string.IsNullOrEmpty(inputPath) && File.Exists(inputPath))
        {
            workbook.LoadFromFile(inputPath);
        }
        // 创建AI文档处理器
        AIDocumentProcessor processor = workbook.AI(options);

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

待拆分的 Excel 工资表 待拆分的 Excel 工资表 拆分生成的独立工资条 PDF 拆分生成的独立工资条 PDF


案例二:套用工资条模板并加密,生成可分发文件

企业发放工资条时,往往还要求两件事:统一版式和保密。企业通常有固定格式的工资条模板——带有公司抬头、发放月份、薪酬项目排列顺序和底部的说明文字;同时,薪酬属于敏感信息,直接以明文 PDF 发出,一旦转发或误投,员工的薪资就会外泄。

更稳妥的做法是:让每份工资条都套用同一个模板,再为每位员工的 PDF 单独设置一个打开密码(例如以其工号为基础生成),只有本人能打开查看自己的工资条。

但密码一旦"各不相同",就带来了新的管理问题:这些密码由谁记录、事后去哪里查。密码由"工号 + 随机四位数字"拼成,其中的随机数字无法从文件名或其他信息反推;几十上百份文件发出去之后,一旦员工反馈打不开,就只能回头翻原始工资表逐个试。因此加密分发的完整闭环里,还应当顺带产出一份分发台账——把文件名、员工信息与打开密码一一对应地记录下来,便于后续分发、核对与归档查询。

本案例以工资条模板作为输入文档,员工薪酬数据则作为附件传入;AI 需要把附件数据逐条填充到模板的对应位置,并在生成加密 PDF 的同时输出这份台账。

以下示例使用 Spire.Agent.Office 智能体,通过自然语言指令读取附件工资表中的员工数据,逐条填充到工资条模板中,为每位员工生成一份独立的 PDF,按"工号 + 随机四位数字"加密,并额外生成一份 Excel 格式的工资条分发台账文件:

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

// 员工薪酬数据文件路径(作为附件传入)
string[] attachmentPaths = new string[]
{
    @"E:\payroll.xlsx"
};

// Excel 处理相关配置
string inputPath = @"E:\template-payslip.xlsx";  // 工资条模板文件路径
string savePath = null;  // 结果文档路径(此处为null,将使用下面设置的输出文件夹路径)
string OutDir = @"E:\output-encrypted";  // 输出目录(加密后的工资条 PDF 与分发台账将保存到此目录)
string key = "**************************";  // SpireToken Key
string instruction =
    "读取附件'payroll.xlsx'中的员工薪酬数据,按以下要求处理:" +
    "1. 首行为表头,其后每一行对应一名员工,逐行读取工号、姓名、部门与各薪酬项目;" +
    "2. 将每行数据逐条填充到工资条模板的对应位置,保持模板的公司抬头、说明文字与项目顺序不变;" +
    "3. 每位员工生成一份独立的 PDF 文件,以'工号+员工姓名'命名保存到输出目录;" +
    "4. 为该 PDF 设置打开密码,密码为'工号+随机的四位数字',仅限制文档打开,不影响打印与复制;" +
    "5. 在输出目录另外生成一份 Excel 格式的工资条分发台账,逐行列出发放月份、工号、姓名、部门、工资条文件名与对应的打开密码,供后续分发与归档查询;" +
    "6. 保留模板的布局样式、字体与列宽设置;" +
    "最终输出保存为PDF文件";

// 调用Excel文档处理函数
AIResult result = ExecuteDemoExcel(instruction, inputPath, savePath, key, OutDir, attachmentPaths);


// 执行Excel文档AI处理
static AIResult ExecuteDemoExcel(string instruction, string inputPath, string savePath, string key, string output, string[] attachmentPaths)
{
    // 创建AIOptions选项配置对象
    AIOptions options = new AIOptions();
    options.WorkDir = output;  // 设置工作目录为输出目录
    options.SpireToken = key;  // 设置SpireToken Key

    // 使用Workbook对象处理Excel文档
    using (Workbook workbook = new Workbook())
    {
        // 从文件加载工资条模板
        if (!string.IsNullOrEmpty(inputPath) && File.Exists(inputPath))
        {
            workbook.LoadFromFile(inputPath);
        }
        // 创建AI文档处理器
        AIDocumentProcessor processor = workbook.AI(options);

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

工资模板文件 工资模板文件 套用模板并加密后的工资条 PDF 与工资条分发台账 套用模板并加密后的工资条 PDF 与工资条分发台账


常见问题

拆分后的工资条出现串行或数据错位

原因:工资表中存在空行、合并单元格或小计行,导致 AI 按行读取时把非员工行也算作一条记录,后续记录整体错位。

解决:确保工资表首行为表头,其后每行对应且仅对应一名员工,中间不留空行与小计行;如果表格结构复杂,可在指令中写明判断规则,例如"仅在'工号'列非空时视为一名员工记录"。

加密后的工资条自己也无法打开

原因:每份工资条的密码按"工号 + 随机四位数字"生成,密码随人不同,其中的随机数字无法自行推算。

解决:在指令中明确密码规则(例如"密码为工号 + 随机的四位数字"),并务必将生成的密码记录到分发台账中——随机数字一旦丢失,文件就无法再打开;分发前可先用台账中的一条记录试开一份验证。


获取 SpireToken Key

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

在代码中配置:

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

图表画出的是均值,看不出这组数据有多稳。同样是一条上升的折线,背后可能是六次几乎一致的测量,也可能是六次忽高忽低的读数——只盯曲线是分不出来的。误差线正是用来补上这一层信息:它在每个数据点上画出一段短线,标明该点的波动或不确定范围,让读者知道曲线上的每个拐点有多少可信度。百分比误差线按数值的固定比例给出范围,标准误差线则依据数据本身的标准误差计算,两者适用于不同的场景。Spire.XLS for JavaScript 基于 WebAssembly 在浏览器端直接完成上述操作,通过虚拟文件系统(VFS)管理输入输出文件,无需后端服务支持。

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

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


为折线图添加百分比误差线

百分比误差线以数据点自身的数值为基数,按给定的百分比算出误差量。例如设为 10 时,数值为 4.0 的点误差量就是 0.4;至于这段长度画在数据点的哪一侧,则由另一个参数决定。这类误差线适合表达「允许偏差」——计划产量允许上下浮动一成,或是仪器读数有固定的相对精度。误差线挂在系列上,因此要先取到系列对象,再调用它的 ErrorBar 方法。具体操作步骤如下:

  1. 将字体与测试数据文件载入 VFS。
  2. 加载工作簿并取得工作表。
  3. 添加折线图,把数据区域指向计划产量一列。
  4. 把月份一列设为分类标签。
  5. 为系列添加百分比误差线,指定方向与误差量并保存工作簿。

以下为完整的代码示例,演示如何在 React 中为折线图添加百分比误差线:

function App() {
  const addPercentageErrorBar = 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/`);
    const inputFileName = 'ErrorBarChartData.xlsx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);

    // 加载工作簿,取第一个工作表
    const workbook = new xlsModule.Workbook();
    workbook.LoadFromFile({ fileName: inputFileName });
    const sheet = workbook.Worksheets.get(0);

    // 添加折线图,数据区域指向计划产量一列
    const chart = sheet.Charts.Add({ chartType: xlsModule.ExcelChartType.Line });
    chart.ChartTitle = "计划产量";
    chart.ChartTitleArea.IsBold = true;
    chart.ChartTitleArea.Size = 12;
    chart.DataRange = sheet.Range.get("B1:B7");
    chart.SeriesDataFromRange = false;

    // 设置图表在工作表中的位置
    chart.TopRow = 9;
    chart.BottomRow = 26;
    chart.LeftColumn = 1;
    chart.RightColumn = 9;

    // 取到系列,把月份一列设为分类标签
    const serie = chart.Series.get(0);
    serie.CategoryLabels = sheet.Range.get("A2:A7");

    // 为系列添加百分比误差线:方向为正,误差量为 10%
    serie.ErrorBar({
      bIsY: true,
      include: xlsModule.ErrorBarIncludeType.Plus,
      type: xlsModule.ErrorBarType.Percentage,
      numberValue: 10.0,
    });

    // 保存工作簿
    const outputFileName = "PercentageErrorBar.xlsx";
    workbook.SaveToFile({ fileName: outputFileName });

    // 释放 workbook 对象以释放资源
    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={addPercentageErrorBar}>Start</button>
    </div>
  );
}

export default App;

运行后,为折线图添加百分比误差线的效果:

为折线图添加百分比误差线


为柱状图添加标准误差线

标准误差线不依赖人工给定的百分比,而是由系列自身的数据算出标准误差——数值波动大的系列,误差线自然就长。这也是它和百分比误差线的分工:前者描述「这组数据本身有多稳」,后者描述「我们允许它偏多少」。柱状图常用来并排比较多组数据,若两组都挂上标准误差线,就能一眼看出哪一组的读数更集中。具体操作步骤如下:

  1. 将字体与测试数据文件载入 VFS。
  2. 加载工作簿并取得工作表。
  3. 添加柱状图,数据区域同时覆盖计划产量与实际产量两列。
  4. 为两个系列分别添加标准误差线。
  5. 保存工作簿。

以下为完整的代码示例,演示如何在 React 中为柱状图添加标准误差线:

function App() {
  const addStandardErrorBar = 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/`);
    const inputFileName = 'ErrorBarChartData.xlsx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);

    // 加载工作簿,取第一个工作表
    const workbook = new xlsModule.Workbook();
    workbook.LoadFromFile({ fileName: inputFileName });
    const sheet = workbook.Worksheets.get(0);

    // 添加柱状图,数据区域覆盖计划产量与实际产量两列
    const chart = sheet.Charts.Add({ chartType: xlsModule.ExcelChartType.ColumnClustered });
    chart.ChartTitle = "计划产量与实际产量";
    chart.ChartTitleArea.IsBold = true;
    chart.ChartTitleArea.Size = 12;
    chart.DataRange = sheet.Range.get("B1:C7");
    chart.SeriesDataFromRange = false;

    // 设置图表在工作表中的位置
    chart.TopRow = 9;
    chart.BottomRow = 26;
    chart.LeftColumn = 1;
    chart.RightColumn = 9;

    // 取到第一个系列,把月份一列设为分类标签
    const serie = chart.Series.get(0);
    serie.CategoryLabels = sheet.Range.get("A2:A7");

    // 第一个系列画标准误差线,方向为负
    serie.ErrorBar({
      bIsY: true,
      include: xlsModule.ErrorBarIncludeType.Minus,
      type: xlsModule.ErrorBarType.StandardError,
      numberValue: 0.3,
    });

    // 第二个系列也画标准误差线,方向为正负双向
    const serie2 = chart.Series.get(1);
    serie2.ErrorBar({
      bIsY: true,
      include: xlsModule.ErrorBarIncludeType.Both,
      type: xlsModule.ErrorBarType.StandardError,
      numberValue: 0.5,
    });

    // 保存工作簿
    const outputFileName = "StandardErrorBar.xlsx";
    workbook.SaveToFile({ fileName: outputFileName });

    // 释放 workbook 对象以释放资源
    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={addStandardErrorBar}>Start</button>
    </div>
  );
}

export default App;

运行后,为柱状图添加标准误差线的效果:

为柱状图添加标准误差线


常见问题

误差线支持哪些方向?

误差线的方向由 ErrorBar 的 include 参数决定,取 ErrorBarIncludeType 中的三个值:

取值 效果
Both 正负偏差,数据点上下各画一段
Minus 负偏差,只画数据点以下的部分
Plus 正偏差,只画数据点以上的部分
// 正负双向都要画
serie.ErrorBar({
  bIsY: true,
  include: xlsModule.ErrorBarIncludeType.Both,
  type: xlsModule.ErrorBarType.Percentage,
  numberValue: 10.0,
});

方向只决定误差线画在数据点的哪一侧,不改变长度:同一组参数下,三种方向取到的误差量完全相同。

支持哪些误差量类型?

误差量类型由 ErrorBar 的 type 参数决定,取 ErrorBarType 中的五个值:

类型 说明
Fixed 固定值,误差量由 numberValue 指定
Percentage 百分比,按数据点数值的百分比换算
StandardDeviation 标准偏差,误差量由 numberValue 指定
StandardError 标准误差,长度由系列数据算出,numberValue 不参与
Custom 自定义,按区域给出正负两侧的误差量

前四种的误差量都写在 numberValue 里:

serie.ErrorBar({
  bIsY: true,
  include: xlsModule.ErrorBarIncludeType.Both,
  type: xlsModule.ErrorBarType.StandardDeviation,
  numberValue: 2,
});

获取免费许可证

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

将 Word 文档转换为 HTML,能够完整保留原有的段落结构、样式与图片,并直接在浏览器中渲染,广泛用于在线预览、内容发布和全文检索等场景。Spire.Doc for JavaScript 基于 WebAssembly 在浏览器端直接完成此转换,通过虚拟文件系统(VFS)管理输入输出文件,无需后端服务支持。

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

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


转换 Word 到 HTML

Word 转 HTML 的核心流程分为三个阶段:首先通过 FetchFileToVFS 将字体文件和目标 Word 文件载入 WASM 虚拟文件系统;然后实例化 Document 加载文件,通过 HtmlExportOptions 指定 CSS 与图片均以内嵌方式输出,再调用 SaveToFile 将文档保存为 HTML;最后从 VFS 读取生成的 HTML 文件,封装为 Blob 后触发浏览器下载。

function App() {
  const wordToHtml = async () => {
    // 获取 Spire.Doc WASM 模块
    const docModule = window.wasmModule?.spiredoc;

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

    // 将字体和 Word 文件载入 VFS
    await window.spire.FetchFileToVFS('ARIALUNI.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/static/font/`);
    const inputFileName = 'ToHtml.docx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/static/data/`);

    // 加载 Word 文档
    const wordDocument = new docModule.Document();
    wordDocument.LoadFromFile(inputFileName);

    // 将 CSS 样式内嵌到 HTML,并将图片以 Base64 内嵌
    wordDocument.HtmlExportOptions.CssStyleSheetType = docModule.CssStyleSheetType.Internal;
    wordDocument.HtmlExportOptions.ImageEmbedded = true;

    // 将文档转换为 HTML
    const outputFileName = 'ToHtml-result.html';
    wordDocument.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Html });

    // 从 VFS 读取转换后的文件,触发下载
    const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([fileArray], { type: 'text/html;charset=utf-8' });
    const url = URL.createObjectURL(blob);
    const a = window.document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);

    // 释放资源
    wordDocument.Dispose();
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>转换Word到HTML</h1>
      <button onClick={wordToHtml}>
        生成
      </button>
    </div>
  );
}

export default App;

Word 文档通过 SaveToFile 转换后生成的 HTML 页面

Word 文档通过 SaveToFile 转换后生成的 HTML 页面


转换 Word 到 HTML 并配置导出选项

上一节的输出是单个 HTML 文件,CSS 与图片都被内嵌其中。当文档体积较大,或希望统一维护样式、复用图片资源时,通常需要将 CSS 与图片导出为独立文件。HtmlExportOptions 提供了对应的配置项,使 HTML、样式表与图片分离输出。

转换流程与前一节类似,区别在于输出结果是一个目录:需要先在 VFS 中创建目录,再通过 CssStyleSheetFileName、ImagesPath 等属性指定各类资源的存放位置,转换完成后递归读取该目录并打包为 zip 一并下载。

import JSZip from 'jszip';

function App() {
  const wordToHtmlWithOptions = async () => {
    // 获取 Spire.Doc WASM 模块
    const docModule = window.wasmModule?.spiredoc;

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

    // 将字体和 Word 文件载入 VFS
    await window.spire.FetchFileToVFS('ARIALUNI.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/static/font/`);
    const inputFileName = 'ToHtml.docx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/static/data/`);

    // 在 VFS 中创建输出目录
    const outputDirectoryName = 'ToHTMLFolder/';
    window.dotnetRuntime.Module.FS.mkdirTree(outputDirectoryName);

    // 加载 Word 文档
    const wordDocument = new docModule.Document();
    wordDocument.LoadFromFile(inputFileName);

    // 将 CSS 样式导出为独立文件
    wordDocument.HtmlExportOptions.CssStyleSheetFileName = outputDirectoryName + 'sample.css';
    wordDocument.HtmlExportOptions.CssStyleSheetType = docModule.CssStyleSheetType.External;

    // 将图片导出到独立目录
    wordDocument.HtmlExportOptions.ImageEmbedded = false;
    wordDocument.HtmlExportOptions.ImagesPath = outputDirectoryName + 'Demo/';

    // 将表单域导出为纯文本
    wordDocument.HtmlExportOptions.IsTextInputFormFieldAsText = true;

    // 将文档转换为 HTML
    const outputFileName = 'ToHtmlExportOption-out.html';
    wordDocument.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Html });

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

    // 递归读取输出目录,将各层级文件写入 zip
    const zip = new JSZip();
    const addFilesToZip = async (folderPath, zipFolder) => {
      let items = await window.dotnetRuntime.Module.FS.readdir(folderPath);
      items = items.filter((item) => item !== '.' && item !== '..');
      for (const item of items) {
        const itemPath = `${folderPath}/${item}`;
        try {
          const fileData = await window.dotnetRuntime.Module.FS.readFile(itemPath);
          zipFolder.file(item, fileData);
        } catch (error) {
          const zipSubFolder = zipFolder.folder(item);
          await addFilesToZip(itemPath, zipSubFolder);
        }
      }
    };

    // HTML 文件与资源目录一并打包
    zip.file(outputFileName, window.dotnetRuntime.Module.FS.readFile(outputFileName));
    await addFilesToZip(outputDirectoryName, zip);
    const zipBlob = await zip.generateAsync({ type: 'blob' });
    const url = URL.createObjectURL(zipBlob);

    // 触发下载
    const a = window.document.createElement('a');
    a.href = url;
    a.download = 'ToHTMLFolder.zip';
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>用导出选项转换Word到HTML</h1>
      <button onClick={wordToHtmlWithOptions}>
        生成
      </button>
    </div>
  );
}

export default App;

配置导出选项后生成的 HTML、CSS 与图片文件

配置导出选项后生成的 HTML、CSS 与图片文件

需要注意的是,Spire.Doc 并不会把图片直接写入 ImagesPath 指向的目录,而是在其下再创建一个 external_images 子目录存放图片。因此输出目录通常会形成 Demo/external_images/*.png 这样的层级,读取时需要逐层递归,这也是上例中 addFilesToZip 采用递归实现的原因。


常见问题

导出 HTML 中的字体与原文不一致

原因:WASM 虚拟文件系统中缺少字体文件。Spire.Doc 在转换时会从 VFS 读取字体以完成排版计算与字体名解析,若未预加载,原文使用的字体会被替换为替代字体,导出的 CSS 中 font-family 与原文不符;若原文使用了 Wingdings 等符号字体,对应的字符还会变成乱码。

解决:转换前通过 FetchFileToVFS 将字体文件载入 VFS,中日韩文档建议使用 ARIALUNI.TTF 这类覆盖完整的字体:

await window.spire.FetchFileToVFS(
  'ARIALUNI.TTF', '/Library/Fonts/', '/'
);

导出的 HTML 打开后样式与图片丢失

原因:使用外链模式(CssStyleSheetType.External 配合 ImageEmbedded = false)时,CSS 与图片会作为独立文件输出到指定目录,HTML 中仅保留相对路径引用。若只下载 HTML 文件,浏览器将找不到对应的样式表与图片,页面会退化为无样式的纯文本。

解决:将 HTML 文件与资源目录一并打包下载,确保相对路径引用有效(见上文 addFilesToZip 示例)。若不需要独立的资源文件,也可改用内嵌模式:

wordDocument.HtmlExportOptions.CssStyleSheetType = docModule.CssStyleSheetType.Internal;
wordDocument.HtmlExportOptions.ImageEmbedded = true;

获取免费许可证

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

当一张图表里的分类本身是分层的——比如先按大区、再按月份,或者先按年份、再按季度——把两列标签压成一行会让读者难以判断某个数据点究竟属于哪个大区、哪个月份。分层数据的另一面是量级差异:销售额以万元计,同比增长率却只有个位数,两者放在同一条数值轴上时,增长率会被压成一条贴底的直线。多层分类标签和次坐标轴分别解决这两个问题。Spire.XLS for JavaScript 基于 WebAssembly 在浏览器端直接完成上述操作,通过虚拟文件系统(VFS)管理输入输出文件,无需后端服务支持。

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

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


创建含多层分类标签的图表

多层分类标签由分类轴呈现,而分类轴有几层取决于系列的分类标签指向几列数据。测试数据中外层标签已经按大区合并好,因此代码里只需让分类标签同时覆盖外层与内层两列。具体操作步骤如下:

  1. 将字体与测试数据文件载入 VFS。
  2. 加载工作簿并取得工作表。
  3. 添加柱状图,并添加带名称的销售额系列。
  4. 把分类标签指向大区与月份两列。
  5. 开启分类轴的多层标签,保存工作簿。

以下为完整的代码示例,演示如何在 React 中创建含多层分类标签的图表:

function App() {
  const createMultiLevelChart = 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/`);
    const inputFileName = 'MultiLevelChartData.xlsx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);

    // 加载工作簿,取第一个工作表
    const workbook = new xlsModule.Workbook();
    workbook.LoadFromFile({ fileName: inputFileName });
    const sheet = workbook.Worksheets.get(0);

    // 添加柱状图
    const chart = sheet.Charts.Add({ chartType: xlsModule.ExcelChartType.ColumnClustered });
    chart.ChartTitle = "销售额";
    chart.Legend.Delete();

    // 添加销售额系列,并指定系列名称
    const serie = chart.Series.Add({ name: "销售额", serieType: xlsModule.ExcelChartType.ColumnClustered });
    serie.Values = sheet.Range.get("C2:C7");

    // 把大区和月份两列一并设为分类标签
    serie.CategoryLabels = sheet.Range.get("A2:B7");

    // 开启多层分类标签,让两层标签各占一行
    chart.PrimaryCategoryAxis.MultiLevelLable = true;

    // 设置图表在工作表中的位置
    chart.LeftColumn = 5;
    chart.TopRow = 1;
    chart.RightColumn = 14;

    // 保存工作簿
    const outputFileName = "MultiLevelLabels.xlsx";
    workbook.SaveToFile({ fileName: outputFileName });

    // 释放 workbook 对象以释放资源
    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={createMultiLevelChart}>Start</button>
    </div>
  );
}

export default App;

CategoryLabels 指向的列数决定了分类轴有几层,因此绑定单列区域时得到的仍是单层标签;MultiLevelLable 控制的是这些层是否按多层排布。

运行后,创建含多层分类标签的图表的效果:

创建含多层分类标签的图表


为图表添加次坐标轴

当同一张图表中的两个系列量级相差悬殊时,共用一条数值轴会让小量级的那个被压成贴底的直线,它的起伏也就无从读起。次坐标轴正是为这类情形准备的:它为该系列单独提供一条数值轴,两套刻度各按自己的量级铺开,互不干涉。做法是把需要独立刻度的系列移出主轴,并把它换成折线——折线不占柱宽,与主轴的柱状系列叠在同一组分类上更易分辨。具体操作步骤如下:

  1. 将字体与测试数据文件载入 VFS。
  2. 加载工作簿并取得工作表。
  3. 添加柱状图,并添加带名称的销售额系列。
  4. 添加同比增长率系列,类型设为折线图。
  5. 把同比增长率系列移到次坐标轴,保存工作簿。

以下为完整的代码示例,演示如何在 React 中为图表添加次坐标轴:

function App() {
  const addSecondaryAxis = 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/`);
    const inputFileName = 'MultiLevelChartData.xlsx';
    await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);

    // 加载工作簿,取第一个工作表
    const workbook = new xlsModule.Workbook();
    workbook.LoadFromFile({ fileName: inputFileName });
    const sheet = workbook.Worksheets.get(0);

    // 添加柱状图
    const chart = sheet.Charts.Add({ chartType: xlsModule.ExcelChartType.ColumnClustered });
    chart.ChartTitle = "销售额与同比增长率";

    // 添加销售额系列,留在主坐标轴上
    const salesSerie = chart.Series.Add({ name: "销售额", serieType: xlsModule.ExcelChartType.ColumnClustered });
    salesSerie.Values = sheet.Range.get("C2:C7");

    // 把大区和月份两列一并设为分类标签
    salesSerie.CategoryLabels = sheet.Range.get("A2:B7");

    // 添加同比增长率系列,类型为折线图
    const growthSerie = chart.Series.Add({ name: "同比增长率", serieType: xlsModule.ExcelChartType.Line });
    growthSerie.Values = sheet.Range.get("D2:D7");

    // 把同比增长率系列移到次坐标轴,让它按百分比刻度单独绘制
    growthSerie.UsePrimaryAxis = false;

    // 开启多层分类标签
    chart.PrimaryCategoryAxis.MultiLevelLable = true;

    // 设置图表在工作表中的位置
    chart.LeftColumn = 5;
    chart.TopRow = 1;
    chart.RightColumn = 14;

    // 保存工作簿
    const outputFileName = "SecondaryAxis.xlsx";
    workbook.SaveToFile({ fileName: outputFileName });

    // 释放 workbook 对象以释放资源
    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={addSecondaryAxis}>Start</button>
    </div>
  );
}

export default App;

UsePrimaryAxis = false 只影响被设置的那个系列,其余系列仍留在主轴;图表会因此多出一组数值轴与分类轴,形成两个刻度区间。Series.Add 可以一并指定系列名称,图例中显示的便是这里传入的名字,而不是自动生成的「系列 1」。

运行后,为图表添加次坐标轴的效果:

为图表添加次坐标轴


常见问题

为什么分类轴只显示了一层标签?

原因:分类轴的层数由 CategoryLabels 指向的区域决定。若绑定的是单列区域(例如 B2:B7),区域里只有一层分类信息,此时把 PrimaryCategoryAxis.MultiLevelLable 设为 true 也只会得到一层标签——该属性控制的是多层标签是否展开显示,并不会补出一层数据。

解决:把 CategoryLabels 指向包含外层标签的多列区域,外层标签所在的单元格则需要在数据中纵向合并:

// 让分类标签覆盖外层与内层两列;外层标签的单元格需纵向合并
serie.CategoryLabels = sheet.Range.get("A2:B7");

为什么两条数值轴的刻度不一样?

原因:主、次两条数值轴各自独立计算刻度,互不相干。PrimaryValueAxis 上的 MinValue、MaxValue、MajorUnit 也只作用于主轴,改它不会影响次轴——两个系列量级悬殊时,次轴自动算出的区间往往并不合适。

解决:用 chart.SecondaryValueAxis 单独指定次轴的刻度:

// 次轴使用 0–20 的刻度,每 5 为一个主刻度
chart.SecondaryValueAxis.MinValue = 0;
chart.SecondaryValueAxis.MaxValue = 20;
chart.SecondaryValueAxis.MajorUnit = 5;

刻度要在系列已经移到次轴之后再设:图表里还没有系列使用次轴时,赋值会被接受,但不会写进文件。


获取免费许可证

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