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

Spire.Cloud 纯前端文档控件

PDF 里的文字看着能选中,真要成批取出来却不顺手:合同里的条款、报表里的数字、说明书里的参数,散在一页页的版式里,选不全、复制下来还带着错位的空格和换行,更没法直接拿去做检索、比对或入库。

Spire.PDF for JavaScript 基于 WebAssembly 在浏览器端加载并解析 PDF 文档,通过虚拟文件系统(VFS)读写文件。本文用 PdfTextExtractor 来实现四种提取:保留布局、不保留布局、只取指定区域,以及只取高亮批注覆盖的文字。

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

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


提取保留布局的 PDF 文本

Spire.PDF for JavaScript 提供 PdfTextExtractor,用于把页面上绘制的文字取回成字符串。不设置任何选项时,它按页面原有的列位置与行距还原文本,表格的列、段落的缩进都会保留下来。提取以页为单位,多页文档要逐页处理再拼接。

提取行为由 PdfTextExtractOptions 控制,常用属性如下:

属性 说明
IsSimpleExtraction 指定是否执行简单文本提取,即不保留版面布局。
IsExtractAllText 指定是否提取全部文本。
ExtractArea 定义提取区域,用 RectangleF 圈定。
IsShowHiddenText 指定是否提取隐藏文本。
function App() {
  const extractTextWithLayout = 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 options = new pdfModule.PdfTextExtractOptions();

    // 逐页提取后拼接
    let text = "";
    for (let i = 0; i < doc.Pages.Count; i++) {
      const page = doc.Pages.get_Item(i);
      const extractor = new pdfModule.PdfTextExtractor(page);
      text += extractor.ExtractText(options);
    }

    // 写入 VFS,再从 VFS 读回触发下载
    const outputFileName = '保留布局文本.txt';
    window.dotnetRuntime.Module.FS.writeFile(outputFileName, text);
    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={extractTextWithLayout}>开始提取</button>
    </div>
  );
}

export default App;

按原始排版提取出的文本,表格列与缩进保持不变:

按原始排版提取出的文本,表格列与缩进保持不变


提取不保留布局的 PDF 文本

只要文字本身、不要版式时,把 PdfTextExtractOptions.IsSimpleExtraction 设为 true,提取就切到简单模式:不再计算列位置,文字按阅读顺序连成一段,适合拿去分词、检索或入库。代价是原本并排的两栏会串到一起,表格也不再对齐。

function App() {
  const extractTextWithoutLayout = 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 options = new pdfModule.PdfTextExtractOptions();
    options.IsSimpleExtraction = true;

    // 逐页提取后拼接
    let text = "";
    for (let i = 0; i < doc.Pages.Count; i++) {
      const page = doc.Pages.get_Item(i);
      const extractor = new pdfModule.PdfTextExtractor(page);
      text += extractor.ExtractText(options);
    }

    // 写入 VFS,再从 VFS 读回触发下载
    const outputFileName = '纯文本.txt';
    window.dotnetRuntime.Module.FS.writeFile(outputFileName, text);
    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={extractTextWithoutLayout}>开始提取</button>
    </div>
  );
}

export default App;

简单模式提取出的文本,文字按阅读顺序连成一段:

简单模式提取出的文本,文字按阅读顺序连成一段


提取 PDF 指定区域的文本

需要只取页面上某一块的文字时,用 PdfTextExtractOptions.ExtractArea 圈定范围。它接收一个 RectangleF,坐标以页面左上角为原点、单位为磅,只有落在这个矩形里的文字会被取出,其余内容一律丢弃。

function App() {
  const extractTextFromArea = 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 page = doc.Pages.get_Item(0);

    // 圈定提取范围:页面左上角为原点,单位为磅
    const options = new pdfModule.PdfTextExtractOptions();
    options.ExtractArea = new pdfModule.RectangleF({ x: 80, y: 180, width: 500, height: 200 });

    const extractor = new pdfModule.PdfTextExtractor(page);
    const text = extractor.ExtractText(options);

    // 写入 VFS,再从 VFS 读回触发下载
    const outputFileName = '区域文本.txt';
    window.dotnetRuntime.Module.FS.writeFile(outputFileName, text);
    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={extractTextFromArea}>开始提取</button>
    </div>
  );
}

export default App;

只取到矩形范围内的文本:

只取到矩形范围内的文本


提取 PDF 中的高亮文本

如果文档里已经用荧光笔划出了重点,就没必要再手工圈坐标:遍历页面的 Annotations,挑出 PdfTextMarkupAnnotationWidget,把它的 Bounds 直接交给 ExtractArea,被标注的文字就取出来了。同一个对象上还能读到 TextMarkupColor,用来判断是哪一种标记颜色。

function App() {
  const extractHighlightedText = 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 page = doc.Pages.get_Item(0);
    const extractor = new pdfModule.PdfTextExtractor(page);

    let text = "";
    // 逐个检查页面上的批注,只处理文本标注类
    for (let i = 0; i < page.Annotations.Count; i++) {
      const annotation = page.Annotations.get_Item(i);
      if (annotation instanceof pdfModule.PdfTextMarkupAnnotationWidget) {
        // 用批注矩形作为提取范围,只取被标注的文字
        const options = new pdfModule.PdfTextExtractOptions();
        options.ExtractArea = annotation.Bounds;
        text += extractor.ExtractText(options) + "\n";

        // 高亮颜色:annotation.TextMarkupColor
      }
    }

    // 写入 VFS,再从 VFS 读回触发下载
    const outputFileName = '高亮文本.txt';
    window.dotnetRuntime.Module.FS.writeFile(outputFileName, text);
    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={extractHighlightedText}>开始提取</button>
    </div>
  );
}

export default App;

只取到高亮批注覆盖的文字:

只取到高亮批注覆盖的文字


常见问题

提取出来的中文是乱码或方框

原因:PDF 里存的是字形编号,不是 Unicode 字符,需要文档自带 ToUnicode 映射表才能还原成可读文字。扫描件、或者生成时没嵌入字体的文档往往缺这张表,ExtractText 只能拿到编号,输出自然是乱码或方框。

解决:先判断文档类型。文字型 PDF 且字体已内嵌时能正常提取;扫描件和缺映射的文档走不了这条路,需要先经 OCR 转成带文字的 PDF,再来提取。

指定区域提取时结果为空

原因:ExtractArea 的坐标口径是页面左上角为原点、单位为磅,如果按 PDF 原生坐标(左下角原点)或屏幕像素去设,矩形会落到没有文字的位置,取出来就是空串。

解决:换成左上原点、单位磅的矩形再传入;不清楚目标文字具体位置时,先把整页设成范围确认能取到,再逐步收窄:

// A4 页面,单位磅,先取整页确认,再逐步收窄矩形
options.ExtractArea = new pdfModule.RectangleF({ x: 0, y: 0, width: 595, height: 842 });

文档里明明有高亮,却提取不到文本

原因:PdfTextMarkupAnnotationWidget 只匹配文本标注类批注(高亮、下划线、波浪线、删除线)。如果文档里的“高亮”其实是用形状、文本框或半透明图片叠出来的,批注集合里根本没有这个类型,循环体一次都不会进。

解决:先确认页面上到底有没有批注,以及是哪种类型:

for (let i = 0; i < page.Annotations.Count; i++) {
  const annotation = page.Annotations.get_Item(i);
  console.log(annotation instanceof pdfModule.PdfTextMarkupAnnotationWidget);
}

Count 为 0 说明高亮不是批注;类型不匹配则改用对应的批注类判断,或者改回上一节的 ExtractArea,手工圈坐标提取。


获取免费许可证

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

图表讲清楚了数据,却讲不清品牌归属和一句话的结论。报告里常见的做法是在图表角上放一个标识,再在空白处标一句「线上销售合计 1208 万元」——把结论放在读者目光已经落着的地方,而不是另起一段正文。这类元素在 Excel 里属于图表自己的形状层,位置以图表为坐标系而不是以单元格为坐标系,用代码添加时这一点最容易出错。除此之外,绘图区那块默认的白底也可以整片换掉,用一张浅色纹理图当背景,让图表与报表其余部分归于同一套视觉。Spire.XLS for JavaScript 基于 WebAssembly 在浏览器端直接完成这些操作,通过虚拟文件系统(VFS)管理输入输出文件,无需后端服务支持。

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

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


在图表中插入图片

报告里的图表常常要带上品牌标识或产品图。图片放进图表内部之后,它就成了图表的一部分:图表挪位置、改尺寸,图片跟着走;把图表复制到别的文档或导出成图片,图片也不会掉队。相形之下,浮在单元格上方的那类图片,图表一经挪动位置便对不齐了。具体操作步骤如下:

  1. 将字体、测试数据文件与图片载入 VFS。
  2. 用 workbook.LoadFromFile 加载工作簿,取第一个工作表上的第一张图表。
  3. 用 chart.Shapes.AddPicture 把图片加进图表,返回值就是这个形状对象。
  4. 设置形状的 Left、Top、Width、Height。图表内形状的坐标以图表自身为基准,四个属性都以图表宽度(Left、Width)或高度(Top、Height)的 4000 分之一为单位。
  5. 用 workbook.SaveToFile 保存工作簿。

以下为完整的代码示例,演示如何在 React 中在图表中插入图片:

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

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

    // 取第一个工作表与其中的图表
    const sheet = workbook.Worksheets.get(0);
    const chart = sheet.Charts.get(0);

    // 将图片插入图表,返回的对象即图表中的这张图片
    const picture = chart.Shapes.AddPicture('logo.png');

    // 定位到图表右上角,并压到合适的大小。不设置时图片会按原始像素铺开,常常盖住大半个图表
    picture.Left = 2850;   // 距图表左边缘 2850/4000
    picture.Top = 110;     // 距图表上边缘 110/4000
    picture.Width = 900;   // 图片宽度 900/4000
    picture.Height = 532;  // 图片高度 532/4000,与源图 320×120 的比例一致

    // 保存工作簿
    const outputFileName = 'AddPictureInChart.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 id="add-picture-in-chart" onClick={addPictureInChart}>在图表中插入图片</button>
    </div>
  );
}

export default App;

运行后,在图表中插入图片的效果:

在图表中插入图片


在图表中插入文本框

图表能表达趋势,却说不出一句结论。某个系列的合计数、一段同比说明、一处异常标注,都可以用文本框直接写在图上,让读者不必在正文里再找一遍数字。文本框与图片同属图表内部的形状,用的是同一套坐标,区别在于它的尺寸要照着文字长度留:宽度留窄了文字会折行,折下来的那一行会被框高裁掉,看上去就像文字丢了半截。具体操作步骤如下:

  1. 将字体与测试数据文件载入 VFS。
  2. 用 workbook.LoadFromFile 加载工作簿,取第一个工作表上的第一张图表。
  3. 用 chart.Shapes.AddTextBox 在图表里建立文本框。
  4. 按文字长度设置 Left、Top、Width、Height,让内容排成一行。
  5. 把要显示的文字写进 Text 属性。
  6. 用 HAlignment、VAlignment 让文字居中,取值分别来自 xlsModule.CommentHAlignType 与 xlsModule.CommentVAlignType。
  7. 用 Fill.ForeColor、Line.ForeColor 与 Line.Weight 设置底色、边框色与边框粗细,使文本框在图表上独立可辨。
  8. 用 workbook.SaveToFile 保存工作簿。

以下为完整的代码示例,演示如何在 React 中在图表中插入文本框:

function App() {
  const addTextBoxInChart = 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 = 'ChartReport.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.get(0);

    // 在图表中插入文本框
    const textBox = chart.Shapes.AddTextBox();

    // 设置文本框的位置与尺寸,单位同样是图表的 1/4000
    textBox.Left = 450;
    textBox.Top = 530;
    textBox.Width = 2100;
    textBox.Height = 340;

    // 写入文字内容
    textBox.Text = '线上销售合计 1208 万元';

    // 文字居中,并加上浅黄底色与蓝色边框,使其在图表上独立可辨
    textBox.HAlignment = xlsModule.CommentHAlignType.Center;
    textBox.VAlignment = xlsModule.CommentVAlignType.Center;
    textBox.Fill.ForeColor = xlsModule.Color.FromArgb(255, 255, 245, 214);
    textBox.Line.ForeColor = xlsModule.Color.FromArgb(255, 46, 106, 176);
    textBox.Line.Weight = 1;

    // 保存工作簿
    const outputFileName = 'AddTextBoxInChart.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 id="add-textbox-in-chart" onClick={addTextBoxInChart}>在图表中插入文本框</button>
    </div>
  );
}

export default App;

运行后,在图表中插入文本框的效果:

在图表中插入文本框


用图片填充绘图区

绘图区那块白底是图表的默认值,报表做久了容易显得千篇一律。把它换成一张浅色纹理图,柱子、网格线和坐标轴标签依旧清清楚楚,背景却有了质感,整张图表也和报表其他部分归到了同一套视觉里。填充图片与摆在图表上的图片形状互不干扰,两者可以同时使用。具体操作步骤如下:

  1. 将字体、测试数据文件与背景图载入 VFS。
  2. 用 workbook.LoadFromFile 加载工作簿,取第一个工作表上的第一张图表。
  3. 用背景图构造一个 xlsModule.Stream 内存流。
  4. 把它交给 chart.PlotArea.Fill.CustomPicture;第二个参数 name 用来指定工作簿里已有的纹理,没有现成纹理时传 'None'。
  5. 用 workbook.SaveToFile 保存工作簿。

以下为完整的代码示例,演示如何在 React 中用图片填充绘图区:

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

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

    // 取第一个工作表与其中的图表
    const sheet = workbook.Worksheets.get(0);
    const chart = sheet.Charts.get(0);

    // 将背景图读入内存流,作为绘图区的填充图片
    const background = new xlsModule.Stream('background.png');
    chart.PlotArea.Fill.CustomPicture({ im: background, name: 'None' });

    // 保存工作簿
    const outputFileName = 'FillPlotAreaWithPicture.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 id="fill-plot-area" onClick={fillPlotAreaWithPicture}>用图片填充绘图区</button>
    </div>
  );
}

export default App;

运行后,用图片填充绘图区的效果:

用图片填充绘图区


常见问题

图表里能不能加箭头、标注线一类的形状

解决:带指向性的标注线、箭头没有专门的接口,可以用文本框代替:把文本框拉成细长条,去掉底色只留边框,摆到需要指示的位置即可。

用图片填充时,填的是绘图区还是整张图表

原因:PlotArea.Fill 与 ChartArea.Fill 是两个不同的对象。前者只覆盖坐标轴围起来的那块区域,图表标题、图例和坐标轴标签都留在图片之外;后者覆盖整个图表,标题和图例同样会落到图片上。

解决:按需要挑一个即可,填充完成后读 Fill.FillType,成功时返回 ShapeFillType.Picture:

// 只填绘图区:标题、图例和坐标轴标签保持原样
const background = new xlsModule.Stream('background.png');
chart.PlotArea.Fill.CustomPicture({ im: background, name: 'None' });

// 铺满整张图表:图片延伸到标题和图例底下
chart.ChartArea.Fill.CustomPicture({ im: new xlsModule.Stream('background.png'), name: 'None' });

获取免费许可证

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

一份做好的报表常常要复用到不同品牌、不同部门的场合,颜色得跟着换。麻烦的地方在于,工作簿里的颜色分两种:一种是写死的 RGB 值,只认自己那一处;另一种指向主题里的槽位,标题栏、表头、隔行底色和边框虽然看上去各不相同,其实都从同一组槽位取值。前一种只能逐个单元格去改,改漏一处就露出旧配色;后一种改一个槽位,整张表一起重绘。Spire.XLS for JavaScript 基于 WebAssembly 在浏览器端直接完成此操作,通过虚拟文件系统(VFS)管理输入输出文件,无需后端服务支持。

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

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


替换主题中的强调色

报表里用得最多的那个颜色通常只有一个:表头铺它、标题栏用它的深色、隔行底纹用它的浅色、边框再用一档更淡的。这些深浅不同的色阶不是各调一次调出来的,而是同一个槽位按不同明暗系数算出来的。因此换肤时不必逐格挑颜色,只把那个槽位换成新的品牌色,深浅各档会自己重算,整张表连同图表一起落到新的色系上。具体操作步骤如下:

  1. 将字体与测试数据文件载入 VFS。
  2. 用 workbook.LoadFromFile 加载工作簿。
  3. 用 workbook.SetThemeColor 替换 xlsModule.ThemeColorType.Accent1 槽位,新颜色用 xlsModule.Color.FromArgb 给出。
  4. 用 workbook.SaveToFile 保存工作簿,标题栏、表头、隔行底色与边框随之换色。

以下为完整的代码示例,演示如何在 React 中替换主题的强调色:

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

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

    // 替换主题中的强调色 1:标题栏、表头、隔行底色和边框会一并换色
    workbook.SetThemeColor(
      xlsModule.ThemeColorType.Accent1,
      xlsModule.Color.FromArgb(255, 46, 125, 91),
    );

    // 保存工作簿
    const outputFileName = "SetThemeColor.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 id="set-theme-color" onClick={setThemeColor}>替换主题中的强调色</button>
    </div>
  );
}

export default App;

运行后,替换主题中的强调色的效果:

替换主题中的强调色


定制整套主题配色

只换一个强调色,表格主体是统一了,可图表里的其他系列、合计行、警示色仍停在旧配色上——它们取的是主题里的其他槽位。要让整份文档彻底换一套色系,就得把六个强调色槽位一起改掉,让所有引用主题的图形元素同时落到新方案上,而不是改一个、留一串。改动前先把当前值读出来留个底,换完之后也能对照确认。具体操作步骤如下:

  1. 将字体与测试数据文件载入 VFS。
  2. 用 workbook.LoadFromFile 加载工作簿,再用 workbook.GetThemeColor 读一眼当前强调色 1 的 R、G、B 分量,作为对照基线。
  3. 把六组「槽位 + 颜色」写进一个数组,颜色仍由 xlsModule.Color.FromArgb 生成。
  4. 遍历数组,对每一项调用 workbook.SetThemeColor,六个强调色一次换完。
  5. 用 workbook.SaveToFile 保存工作簿。

以下为完整的代码示例,演示如何在 React 中定制整套主题配色:

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

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

    // 换色前先读一眼当前的强调色,便于和换色后的结果对照
    const before = workbook.GetThemeColor(xlsModule.ThemeColorType.Accent1);
    console.log(`换色前的强调色 1:R=${before.R} G=${before.G} B=${before.B}`);

    // 六种强调色一次换完,整套配色随之改变
    const scheme = [
      [xlsModule.ThemeColorType.Accent1, xlsModule.Color.FromArgb(255, 109, 46, 95)],
      [xlsModule.ThemeColorType.Accent2, xlsModule.Color.FromArgb(255, 18, 89, 94)],
      [xlsModule.ThemeColorType.Accent3, xlsModule.Color.FromArgb(255, 138, 106, 22)],
      [xlsModule.ThemeColorType.Accent4, xlsModule.Color.FromArgb(255, 47, 93, 58)],
      [xlsModule.ThemeColorType.Accent5, xlsModule.Color.FromArgb(255, 67, 48, 122)],
      [xlsModule.ThemeColorType.Accent6, xlsModule.Color.FromArgb(255, 138, 59, 46)],
    ];
    for (const [themeColorType, color] of scheme) {
      workbook.SetThemeColor(themeColorType, color);
    }

    // 保存工作簿
    const outputFileName = "ApplyThemeScheme.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 id="apply-theme-scheme" onClick={applyThemeScheme}>定制整套主题配色</button>
    </div>
  );
}

export default App;

运行后,定制整套主题配色的效果:

定制整套主题配色


沿用其他工作簿的主题

配色方案定稿之后,往往已经躺在一份现成的工作簿里——设计给的样板、上一季度的报表、或者公司统一的模板。这时候再照着色号一个槽位一个槽位地抄,既费事又容易抄错一两位。既然主题本身就是工作簿里的一部分,可以直接把整份主题搬过来,连深浅两对底色和超链接颜色一并带走,目标工作簿里所有引用主题的颜色随之重绘。提供主题的工作簿不必与目标工作簿同形同构,行列多少、放的是哪套数据、画的是哪类图表都不碍事,搬过来的只是主题本身,目标工作簿原有的数据与图表不会被牵动。具体操作步骤如下:

  1. 将字体与两个工作簿文件载入 VFS。
  2. 用 workbook.LoadFromFile 加载待换肤的目标工作簿,再用 themeWorkbook.LoadFromFile 加载提供主题的源工作簿。
  3. 用 workbook.CopyTheme(themeWorkbook) 把源工作簿的主题整套搬到目标工作簿。
  4. 用 workbook.SaveToFile 保存目标工作簿,最后分别对两个 workbook 对象调用 Dispose。

以下为完整的代码示例,演示如何在 React 中沿用其他工作簿的主题:

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

    // 加载待换肤的工作簿
    const workbook = new xlsModule.Workbook();
    workbook.LoadFromFile({ fileName: inputFileName });

    // 加载提供主题的源工作簿
    const themeWorkbook = new xlsModule.Workbook();
    themeWorkbook.LoadFromFile({ fileName: themeFileName });

    // 把源工作簿的主题整套搬到目标工作簿
    workbook.CopyTheme(themeWorkbook);

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

    // 释放两个 workbook 对象以释放资源
    workbook.Dispose();
    themeWorkbook.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 id="copy-workbook-theme" onClick={copyWorkbookTheme}>沿用其他工作簿的主题</button>
    </div>
  );
}

export default App;

运行后,沿用其他工作簿的主题的效果:

沿用其他工作簿的主题


常见问题

哪些颜色会随主题变化,哪些不会

原因:颜色分为两类。在 Excel 中由「主题颜色」指定的颜色,会随主题一同变化;由「标准色」指定的颜色是固定值,主题变化时不会改变。

解决:若需颜色随主题变化,在 Excel 中选中单元格,从「主题颜色」中选择。代码中写入的颜色均为固定值,不随主题变化:

const cell = sheet.Range.get('A1');

// 固定色:不随主题变化
cell.Style.Interior.Color = xlsModule.Color.FromArgb(255, 192, 0, 0);

只改图表、不动表格可以吗

原因:主题属于整份工作簿,不区分表格与图表。修改主题时,所有取用主题颜色的对象会一同变化,表格与图表都在其中。图表的系列本身并不保存颜色,绘制时取自主题,因此主题一变,图表随之改变,无法只让其中一方变化。

解决:若只需调整图表,则不要修改主题,直接为系列单独设色。这样写入图表的是固定颜色,不随主题变化:

const chart = sheet.Charts.get(0);
const serie = chart.Series.get(0);

serie.Format.Fill.FillType = xlsModule.ShapeFillType.SolidColor;
serie.Format.Fill.ForeColor = xlsModule.Color.FromArgb(255, 46, 125, 91);

获取免费许可证

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

PDF 的属性面板里除了标题、作者这几个固定字段,还留了一栏自定义属性:属性名和值都由使用者自己命名,部门、保密级别、来源模板这类内部标记就放在这里。合同、投标书、项目文档常靠它携带这些信息,可阅读器只能一个个手填——给一批文档补标记,或者核对某份文档带了哪些标记,都做不了;把文件传到服务端批处理,又意味着内容离开了用户的设备。

本文介绍用 Spire.PDF for JavaScript 添加、获取和删除 PDF 的自定义文档属性。它基于 WebAssembly 在浏览器端直接加载、修改与保存 PDF 文档,全过程在本地完成,通过虚拟文件系统(VFS)读写文件,无需后端配合。

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

有关安装和项目配置,请参考 React 项目中集成 Spire.PDF for JavaScript。以下示例默认已安装 Spire.PDF 并完成 WebAssembly 模块初始化。示例以一份不带自定义属性的 PDF 为输入,后两节直接读取第一节生成的 已添加自定义属性的文档.pdf,运行前请先执行第一节的代码。


添加自定义文档属性

Spire.PDF for JavaScript 提供 DocumentInformation.SetCustomProperty(),用于写入自定义文档属性:属性名自定,值按字符串保存,同名键直接覆盖。

function App() {
  const addCustomProperties = 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 文档
    const doc = new pdfModule.PdfDocument();
    doc.LoadFromFile(inputFileName);

    // 写入自定义文档属性,属性名自定
    doc.DocumentInformation.SetCustomProperty('Department', '研发部');
    doc.DocumentInformation.SetCustomProperty('SecrecyLevel', '内部');
    doc.DocumentInformation.SetCustomProperty('Company', '冰蓝科技');

    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={addCustomProperties}>
        开始添加
      </button>
    </div>
  );
}

export default App;

产物在阅读器属性面板的自定义栏里多了 Department、SecrecyLevel 与 Company 三个属性

添加自定义属性后的 PDF 文档


获取自定义文档属性

读取走同一个 DocumentInformation:GetCustomProperty() 按属性名取值,键不存在时返回 null 而不是抛异常。

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

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

    // 读取上一节生成的文档,它已在 VFS 中
    const inputFileName = '已添加自定义属性的文档.pdf';

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

    const info = doc.DocumentInformation;

    // 按属性名逐个取值,键不存在时返回 null
    const lines = [
      `Department: ${info.GetCustomProperty('Department')}`,
      `SecrecyLevel: ${info.GetCustomProperty('SecrecyLevel')}`,
      `Company: ${info.GetCustomProperty('Company')}`,
      `Owner: ${info.GetCustomProperty('Owner') ?? '(未设置)'}`,
    ];

    // 把结果写成文本文件
    const outputFileName = '自定义属性.txt';
    window.dotnetRuntime.Module.FS.writeFile(outputFileName, lines.join('\n'));
    doc.Close();

    // 从 VFS 读取生成的文件,触发下载
    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={getCustomProperties}>
        开始获取
      </button>
    </div>
  );
}

export default App;

导出的文本文件,逐行列出读到的属性值:

导出的自定义属性文本文件


删除自定义文档属性

删除用 RemoveCustomProperty(),按属性名移除单个键。

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

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

    // 读取上一节生成的文档,它已在 VFS 中
    const inputFileName = '已添加自定义属性的文档.pdf';

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

    // 按属性名移除单个自定义属性,其余属性不受影响
    doc.DocumentInformation.RemoveCustomProperty('SecrecyLevel');
    doc.DocumentInformation.RemoveCustomProperty('Company');

    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={removeCustomProperties}>
        开始删除
      </button>
    </div>
  );
}

export default App;

产物只剩 Department 一个自定义属性,被删掉的两个已从面板中消失:

删除自定义属性后的 PDF 文档


常见问题

自定义属性和标题、作者这些字段有什么区别

原因:PDF 规范固定了 Title、Author、Subject、Keywords、Creator、Producer 这几个标准字段,规范之外的键值对都算自定义属性。阅读器把它们分两栏显示:标准字段在上半部分,自定义属性单独一栏。

解决:两类字段用两套写法。标题、作者这类标准字段赋给同名属性,业务标记走自定义属性:

// 标准字段
doc.DocumentInformation.Title = '2026 年度产品介绍';
doc.DocumentInformation.Author = '市场部';

// 自定义属性
doc.DocumentInformation.SetCustomProperty('Department', '市场部');

读的时候同样分开:标准字段用 info.Title 这样的属性取值,自定义属性只能用 info.GetCustomProperty('Department')。

删除之后读回是什么,键名写错了怎么办

原因:GetCustomProperty() 对不存在的键一律返回 null,删掉的和从来没有写过的看不出区别;RemoveCustomProperty() 传入不存在的键既不报错,也不改动文档。

解决:删完重新打开产物复核一次,返回 null 表示已移除,同时确认其他属性仍读得到。键名传错没有副作用,按正确的键名再删一次即可:

doc.DocumentInformation.RemoveCustomProperty('SecrecyLevel');
doc.SaveToFile(outputFileName);

// 重新打开产物复核:null 即已删除
const check = new pdfModule.PdfDocument();
check.LoadFromFile(outputFileName);
console.log(check.DocumentInformation.GetCustomProperty('SecrecyLevel'));

获取免费许可证

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

排版规范的文档,价值不只在版面本身——段落挂了哪个样式,本身就是一份结构化信息。标题段落用的是 Heading 1 还是 Heading 2,正文用的是什么样式,都能反映文档的层次。反过来,如果要把文档里的标题抽出来生成目录或摘要,最可靠的做法也是「按样式名取」,而不是去猜哪一行字比较大。

Spire.Doc for JavaScript 中每个段落都带一个 StyleName 属性,读取它即可拿到该段落当前使用的样式名。本文的两个示例输出的都是 TXT 文本文件。

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

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


导出文档的样式名清单

文档的段落按「节 → 段落」两级组织:doc.Sections 是节集合,每个 section 的 Paragraphs 是段落集合。两层都用 Count + get_Item(index) 遍历,逐段读 StyleName 并拼接起来即可。

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

    // 将示例文件载入虚拟文件系统(VFS)
    let inputFileName = "RetrieveStyle.docx";
    await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);

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

    // 遍历全部节 → 段落,读取每段的 StyleName
    let styleName = "";
    for (let i = 0; i < doc.Sections.Count; i++) {
      let section = doc.Sections.get_Item(i);
      for (let j = 0; j < section.Paragraphs.Count; j++) {
        let paragraph = section.Paragraphs.get_Item(j);
        styleName += paragraph.StyleName + "\r\n";
      }
    }

    // 定义输出文件名
    const outputFileName = "RetrieveStyle-result.txt";

    // 将内容写入 TXT 文件(直接写虚拟文件系统,不经过 SaveToFile)
    window.dotnetRuntime.Module.FS.writeFile(outputFileName, styleName);
    doc.Close();

    // 读取保存的文件并转换为 Blob 对象
    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], { 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={RetrieveStyle}>开始</button>
    </div>
  );
}
export default App;

示例文档共 14 个段落,导出的 TXT 按段落顺序逐行列出样式名:

Title
Heading1
Normal
Heading2
Normal
Heading1
Normal
Heading2
Normal
Heading1
Normal
ListBullet
ListBullet
ListBullet

可以看到内置样式的名称是去掉空格的形式(Heading1 而非 Heading 1,ListBullet 而非 List Bullet)——这一点在按样式名做判断时很关键。

导出样式名清单后的效果


按样式名提取段落文本

沿用同样的两层遍历,只需要在读到每一段时多一次判断:StyleName 等于目标样式名才把 paragraph.Text 收进结果。示例文档里共有 3 个 Heading1 段落,其余是标题样式、二级标题与正文,用于验证筛选确实只挑中了目标样式。

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

    // 将示例文件载入虚拟文件系统(VFS)
    let inputFileName = "GetTextByStyleName.docx";
    await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);

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

    // 收集符合条件的段落文本
    let builder = [];

    // 遍历全部节 → 段落
    for (let i = 0; i < doc.Sections.Count; i++) {
      let section = doc.Sections.get_Item(i);
      for (let j = 0; j < section.Paragraphs.Count; j++) {
        let para = section.Paragraphs.get_Item(j);

        // 只收样式名为 Heading1 的段落
        if (para.StyleName == "Heading1") {
          builder.push(para.Text);
        }
      }
    }

    // 定义输出文件名
    const outputFileName = "GetTextByStyleName-result.txt";

    // 将内容写入 TXT 文件
    window.dotnetRuntime.Module.FS.writeFile(outputFileName, builder.join("\n"));
    doc.Close();

    // 读取保存的文件并转换为 Blob 对象
    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], { 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={GetTextByStyleName}>开始</button>
    </div>
  );
}
export default App;

导出的 TXT 中只有 3 行,正是文档里全部 Heading1 段落:

一、上周事项回顾
二、本周重点
三、待确认事项

按样式名提取文本后的效果


常见问题

按样式名筛不出任何段落

原因:内置样式的 StyleName 是不含空格的形式,写 "Heading 1" 会一条都匹配不到。

解决:改用不带空格的名称。示例中 "Heading1" 能命中 3 段,而 "Heading 1" 命中 0 段:

// 正确
if (para.StyleName == "Heading1") { ... }

// 匹配不到
if (para.StyleName == "Heading 1") { ... }

名称是区分大小写的,建议先跑一遍「导出样式名清单」确认文档里的实际名称再写判断条件。

导出的 TXT 每行之间多了一个空行

原因:拼接时同时用了 push(文本 + "\n") 与 join("\n"),每个元素已经自带换行符,join 又补了一次,于是每两条之间多出一个空行。

解决:两者只保留一个——要么元素不带换行符、由 join 统一补,要么元素自带换行符、直接用 join("") 拼接:

// 由 join 统一补换行
builder.push(para.Text);
window.dotnetRuntime.Module.FS.writeFile(outputFileName, builder.join("\n"));

下载得到的文件打开是乱码

原因:Blob 的 MIME 类型写成了 docx,或读取时把文本当成了二进制文档,用 Word 打开自然显示异常。

解决:TXT 输出要声明 text/plain,并用记事本等文本编辑器打开:

const modifiedFile = new Blob([modifiedFileArray], { type: "text/plain" });

遍历 Sections 与 Paragraphs 时漏掉了表格里的段落

原因:section.Paragraphs 只包含正文段落,表格单元格里的段落不在其中。表格内容挂在 section.Tables 下,需要另行遍历每个单元格的 Paragraphs。

解决:若文档含表格且需要一并处理,在正文循环之外单独遍历表格:

for (let t = 0; t < section.Tables.Count; t++) {
  let table = section.Tables.get_Item(t);
  for (let r = 0; r < table.Rows.Count; r++) {
    for (let c = 0; c < table.Rows.get_Item(r).Cells.Count; c++) {
      let cell = table.Rows.get_Item(r).Cells.get_Item(c);
      // cell.Paragraphs 里是单元格内的段落
    }
  }
}

获取免费许可证

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

上一类需求里,列表只要编号连续就够了;但实际文档中经常需要「另起一组,从指定数字开始」,或者把默认的圆点换成更醒目的符号。这两件事改的都不是段落,而是列表样式本身的层级参数——它们都挂在 ListRef.Levels 上,只是分别对应不同的属性:起始编号用 StartAt,符号字形用 BulletCharacter。Spire.Doc for JavaScript 让这些参数能在浏览器端直接读写。

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

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


让列表从指定编号重新开始

如果两组列表共用同一个样式对象,Word 会把它们视为同一份编号序列,第二组会接着第一组的数字往下排。要让第二组重新计数,最直接的做法是为它单独建一个列表样式,并设置该样式第 0 级的 StartAt 属性——它决定这一级从哪个数字开始。

StartAt 的下标语义与 ListLevelNumber 一致:get_Item(0) 即第一级。示例中第二个列表设为 10,因此它的第一条显示为 10.:

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

    // 创建文档与节
    let doc = new docModule.Document();
    let section = doc.AddSection();

    // 分组小标题
    let paragraph = section.AddParagraph();
    paragraph.AppendText("第一组列表");

    // 第一个列表样式:默认从 1 开始
    let numberList = doc.Styles.Add({ listType: docModule.ListType.Numbered, name: "Numbered1" });
    doc.Styles.Add(numberList);

    // 逐段应用样式
    paragraph = section.AddParagraph();
    paragraph.AppendText("事项一");
    paragraph.ListFormat.ApplyStyle(numberList.Name);

    paragraph = section.AddParagraph();
    paragraph.AppendText("事项二");
    paragraph.ListFormat.ApplyStyle(numberList.Name);

    paragraph = section.AddParagraph();
    paragraph.AppendText("事项三");
    paragraph.ListFormat.ApplyStyle(numberList.Name);

    paragraph = section.AddParagraph();
    paragraph.AppendText("事项四");
    paragraph.ListFormat.ApplyStyle(numberList.Name);

    // 分组小标题
    paragraph = section.AddParagraph();
    paragraph.AppendText("第二组列表");

    // 第二个列表样式:把第 0 级的起始编号设为 10
    let numberList2 = doc.Styles.Add({ listType: docModule.ListType.Numbered, name: "Numbered2" });
    numberList2.ListRef.Levels.get_Item(0).StartAt = 10;
    doc.Styles.Add(numberList2);

    // 逐段应用第二个样式,编号从 10 开始
    paragraph = section.AddParagraph();
    paragraph.AppendText("事项五");
    paragraph.ListFormat.ApplyStyle(numberList2.Name);

    paragraph = section.AddParagraph();
    paragraph.AppendText("事项六");
    paragraph.ListFormat.ApplyStyle(numberList2.Name);

    paragraph = section.AddParagraph();
    paragraph.AppendText("事项七");
    paragraph.ListFormat.ApplyStyle(numberList2.Name);

    paragraph = section.AddParagraph();
    paragraph.AppendText("事项八");
    paragraph.ListFormat.ApplyStyle(numberList2.Name);

    // 定义输出文件名
    const outputFileName = "RestartList-result.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={RestartList}>开始</button>
    </div>
  );
}
export default App;

第一组列表编号为 1~4,第二组从 10 开始,编号为 10~13

设置 StartAt 后列表重新编号的效果


自定义项目符号的显示字形

项目符号列表默认使用 · 之类的圆点。要换成别的形状,需要改两个属性:

  • BulletCharacter —— 符号本身,它是一个字符,可以用 String.fromCharCode() 按 ASCII/Unicode 码位生成;
  • CharacterFormat.FontName —— 承载该字符的字体。符号形状其实是由字体决定的,同一码位在不同字体下是不同图形。

关键在于第二点:Wingdings 这类符号字体把普通字母映射成了几何图形,因此同一个字符码在 Wingdings 下会呈现出与常规字体完全不同的样子。下面的示例用四个不同码位建了四个列表样式,每个样式套用同一段文字,便于横向比较符号差异:

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

    // 创建文档与节
    let doc = new docModule.Document();
    let section = doc.AddSection();

    // 用 ASCII 码指定符号,并指定承载符号的字体为 Wingdings
    let listStyle1 = doc.Styles.Add({ listType: docModule.ListType.Bulleted, name: "liststyle" });
    listStyle1.ListRef.Levels.get_Item(0).BulletCharacter = String.fromCharCode(0x006e);
    listStyle1.ListRef.Levels.get_Item(0).CharacterFormat.FontName = "Wingdings";

    let listStyle2 = doc.Styles.Add({ listType: docModule.ListType.Bulleted, name: "liststyle2" });
    listStyle2.ListRef.Levels.get_Item(0).BulletCharacter = String.fromCharCode(0x0075);
    listStyle2.ListRef.Levels.get_Item(0).CharacterFormat.FontName = "Wingdings";

    let listStyle3 = doc.Styles.Add({ listType: docModule.ListType.Bulleted, name: "liststyle3" });
    listStyle3.ListRef.Levels.get_Item(0).BulletCharacter = String.fromCharCode(0x00b2);
    listStyle3.ListRef.Levels.get_Item(0).CharacterFormat.FontName = "Wingdings";

    let listStyle4 = doc.Styles.Add({ listType: docModule.ListType.Bulleted, name: "liststyle4" });
    listStyle4.ListRef.Levels.get_Item(0).BulletCharacter = String.fromCharCode(0x00d8);
    listStyle4.ListRef.Levels.get_Item(0).CharacterFormat.FontName = "Wingdings";

    // 四个段落使用同一段文字,分别套用四种列表样式
    let p1 = section.Body.AddParagraph();
    p1.AppendText("同一段文字,四种项目符号");
    p1.ListFormat.ApplyStyle(listStyle1.Name);

    let p2 = section.Body.AddParagraph();
    p2.AppendText("同一段文字,四种项目符号");
    p2.ListFormat.ApplyStyle(listStyle2.Name);

    let p3 = section.Body.AddParagraph();
    p3.AppendText("同一段文字,四种项目符号");
    p3.ListFormat.ApplyStyle(listStyle3.Name);

    let p4 = section.Body.AddParagraph();
    p4.AppendText("同一段文字,四种项目符号");
    p4.ListFormat.ApplyStyle(listStyle4.Name);

    // 定义输出文件名
    const outputFileName = "ASCIICharactersBulletStyle-result.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>使用 ASCII 字符创建项目符号样式</h1>
      <button onClick={ASCIICharactersBulletStyle}>开始</button>
    </div>
  );
}
export default App;

四行相同的文字配上了四种不同的项目符号,差异来自 BulletCharacter 码位

自定义项目符号后的效果


常见问题

设置了 StartAt,但列表仍接着上一组继续编号

原因:新旧两组列表复用了同一个样式对象。同一个样式的所有引用共享同一份编号序列,改 StartAt 等于把整条序列的起点都改掉。

解决:为需要重新计数的那一组单独 Add 一个新样式,并在新样式上设置 StartAt:

let numberList2 = document.Styles.Add({ listType: wasmModule.ListType.Numbered, name: "Numbered2" });
numberList2.ListRef.Levels.get_Item(0).StartAt = 10;

自定义的项目符号显示成了方框或乱码

原因:只设了 BulletCharacter 却没设字体,或设成了不含该字形的常规字体,系统无法渲染对应码位,就会退化成缺字方块。

解决:把符号字体设为确实包含该字形的符号字体(如 Wingdings):

listStyle1.ListRef.Levels.get_Item(0).BulletCharacter = String.fromCharCode(0x006e);
listStyle1.ListRef.Levels.get_Item(0).CharacterFormat.FontName = "Wingdings";

列表层级参数改了却没有生效

原因:ListRef.Levels 的下标从 0 开始,若按日常习惯从 1 开始写,改到的其实是第二级,第一级纹丝不动。

解决:确认下标——get_Item(0) 是第一级:

// 第一级
numberList.ListRef.Levels.get_Item(0).StartAt = 10;
// 第二级
numberList.ListRef.Levels.get_Item(1).NumberPrefix = "%1.";

获取免费许可证

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

字体名称、字号与文字颜色是 Word 文档中最基础、也最常用的字符级排版属性。统一全文字体、为标题换用更醒目的字体、把关键结论标成醒目的颜色,几乎出现在每一个文档处理需求里。Spire.Doc for JavaScript 基于 WebAssembly 在浏览器端直接完成这些操作,通过虚拟文件系统(VFS)管理输入输出文件,无需后端服务支持。

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

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


设置字体

设置字体的核心流程分为三个阶段:首先通过 FetchFileToVFS 将字体文件和目标 Word 文档载入 WASM 虚拟文件系统;然后实例化 Document 加载文档,新建一个 CharacterFormat 对象并设置 FontName 与 FontSize,再遍历目标段落的子对象,对其中类型为 TextRange 的对象调用 ApplyCharacterFormat 应用该格式;最后从 VFS 读取保存后的文件,封装为 Blob 后生成下载链接。

需要留意的是,CharacterFormat 的构造函数必须传入该格式所属的 Document 实例,这是 Spire.Doc 中所有独立格式对象的统一约定。

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

    // 将字体文件载入 VFS
    await window.spire.FetchFileToVFS("ARIALUNI.TTF", "/Library/Fonts/", `${process.env.PUBLIC_URL}static/font/`);

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

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

    // 获取第一节的第 2 个段落
    const p = doc.Sections.get_Item(0).Paragraphs.get_Item(1);

    // 创建 CharacterFormat 并设置字体名称与字号
    const format = new docModule.CharacterFormat(doc);
    format.FontName = "Arial Unicode MS";
    format.FontSize = 16;

    // 遍历段落中的子对象,对文本区域应用字符格式
    for (let i = 0; i < p.ChildObjects.Count; i++) {
      const childObj = p.ChildObjects.get_Item(i);
      if (childObj instanceof docModule.TextRange) {
        childObj.ApplyCharacterFormat(format);
      }
    }

    // 定义输出文件名
    const outputFileName = "SetFont_out.docx";

    // 将文档保存到 VFS
    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={SetFont}>开始</button>
    </div>
  );
}
export default App;

设置字体后生成的文档效果

设置字体后生成的文档效果


修改字体颜色

修改字体颜色的核心流程与设置字体一致,区别在于不必再创建独立的 CharacterFormat 对象:颜色是单个属性,直接对 TextRange 自身的 CharacterFormat.TextColor 赋值即可。颜色值取自 Color 提供的预定义属性,其命名与 .NET 的 KnownColor 一致,例如 get_RosyBrown()、get_DarkGreen();需要精确指定色值时,改用 Color.FromArgb(r, g, b)。

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

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

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

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

    // 将第 1 个段落中的文字改为玫瑰棕色
    const p1 = section.Paragraphs.get_Item(0);
    for (let i = 0; i < p1.ChildObjects.Count; i++) {
      const childObj = p1.ChildObjects.get_Item(i);
      if (childObj instanceof docModule.TextRange) {
        childObj.CharacterFormat.TextColor = docModule.Color.get_RosyBrown();
      }
    }

    // 将第 2 个段落中的文字改为深绿色
    const p2 = section.Paragraphs.get_Item(1);
    for (let i = 0; i < p2.ChildObjects.Count; i++) {
      const childObj = p2.ChildObjects.get_Item(i);
      if (childObj instanceof docModule.TextRange) {
        childObj.CharacterFormat.TextColor = docModule.Color.get_DarkGreen();
      }
    }

    // 定义输出文件名
    const outputFileName = "ChangeFontColor_out.docx";

    // 将文档保存到 VFS
    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={ChangeFontColor}>开始</button>
    </div>
  );
}
export default App;

修改字体颜色后生成的文档效果

修改字体颜色后生成的文档效果


常见问题

只想改段落里的某几个词,不是整段

原因:上面的示例以「段落」为最小处理单位——它遍历段落中的全部子对象,只要命中 TextRange 就无条件赋值,因此一旦某个段落被处理,整段文字都会变成同一种颜色或字体。若要按词处理,需要先定位到目标文本所对应的 TextRange,再单独设置它的字符格式。

解决:用 FindAllString 在文档中查找目标文本,它返回的每个 TextSelection 都能通过 GetAsOneRange() 拿到对应的 TextRange,只改这一处即可:

// 查找文档中所有出现的「关键结论」,参数依次为:查找内容、区分大小写、全字匹配
const selections = doc.FindAllString("关键结论", false, true);

for (let i = 0; i < selections.length; i++) {
  // 只对命中的这一小段文本设置颜色,段落中其余文字不受影响
  selections[i].GetAsOneRange().CharacterFormat.TextColor = docModule.Color.get_Red();
}

若只需要替换第一处,把 FindAllString 换成 FindString 会返回单个 TextSelection 对象。

设置了字体,文档在别的电脑上却显示成别的字体

原因:FontName 只是把字体名称写进文档的字符属性,并不会把字体文件本身带进文档。FetchFileToVFS 载入的字体只服务于本次浏览器端的渲染与度量;当文档在另一台机器上用 Word 打开时,如果该字体没有安装,Word 会按自己的字体替换表回退到其他字体,字号与行距也可能随之变化。

解决:若目标字体是通用字体(如 Arial、Times New Roman),通常无需处理;若必须使用某款特定字体,就要把字体文件随文档一起嵌入,这样在未安装该字体的机器上也能正常显示。具体做法见下一篇文章中的「嵌入私有字体」。


获取免费许可证

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

样式是 Word 里复用格式的方式:把一套格式(字体、字号、颜色、段落间距……)命名保存下来,之后任何段落只要挂上这个样式名,就自动获得这一整套格式。改样式定义,所有引用它的段落同步更新——这正是「直接给段落设格式」做不到的。

结构上,一个段落样式同时持有字符格式和段落格式两部分。取用内置样式后,通过 ParagraphStyle.CharacterFormat 改文字层面的属性,通过 ParagraphStyle.ParagraphFormat 改段落层面的属性。Spire.Doc for JavaScript 把这套模型完整映射到 Document.Styles 集合上。

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

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


取用并修改内置样式

Word 内置了 Title、Normal、Heading 1~9 等一批样式。document.AddStyle({ builtinStyle }) 会取出(若尚不存在则创建)指定的内置样式并返回它的对象,拿到之后就可以改写。

需要注意 AddStyle 的返回类型是通用的 Style,改写段落格式前要先确认它确实是段落样式——用 instanceof wasmModule.ParagraphStyle 判断即可。Normal 作为正文基准样式,改它会连带影响所有继承自它的样式,通常只用来统一正文字体与字号。

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

    await window.spire.FetchFileToVFS("msyh.ttc", "/Library/Fonts/", `${process.env.PUBLIC_URL}static/font/`);
    await window.spire.FetchFileToVFS("msyhbd.ttc", "/Library/Fonts/", `${process.env.PUBLIC_URL}static/font/`);

    // 创建文档与节
    let doc = new docModule.Document();
    let sec = doc.AddSection();

    // 取用内置「标题」样式并改写为自定义配色:下边框 + 左对齐
    let titleStyle = doc.AddStyle({ builtinStyle: docModule.BuiltinStyle.Title });

    // 判断是否为段落样式,是则同时设置段落格式
    if (titleStyle instanceof docModule.ParagraphStyle) {
      let ps = titleStyle;
      ps.CharacterFormat.FontName = "微软雅黑";
      ps.CharacterFormat.FontSize = 28;
      ps.CharacterFormat.TextColor = docModule.Color.FromArgb(42, 123, 136);
      ps.ParagraphFormat.Borders.Bottom.BorderType = docModule.BorderStyle.Single;
      ps.ParagraphFormat.Borders.Bottom.Color = docModule.Color.FromArgb(42, 123, 136);
      ps.ParagraphFormat.Borders.Bottom.LineWidth = 1.5;
      ps.ParagraphFormat.HorizontalAlignment = docModule.HorizontalAlignment.Left;
    }

    // 正文样式:统一正文字体与字号
    let normalStyle = doc.AddStyle({ builtinStyle: docModule.BuiltinStyle.Normal });
    normalStyle.CharacterFormat.FontName = "微软雅黑";
    normalStyle.CharacterFormat.FontSize = 11;

    // 一级标题样式
    let heading1Style = doc.AddStyle({ builtinStyle: docModule.BuiltinStyle.Heading1 });
    heading1Style.CharacterFormat.FontName = "微软雅黑";
    heading1Style.CharacterFormat.FontSize = 14;
    heading1Style.CharacterFormat.Bold = true;
    heading1Style.CharacterFormat.TextColor = docModule.Color.FromArgb(42, 123, 136);

    // 二级标题样式
    let heading2Style = doc.AddStyle({ builtinStyle: docModule.BuiltinStyle.Heading2 });
    heading2Style.CharacterFormat.FontName = "微软雅黑";
    heading2Style.CharacterFormat.FontSize = 12;
    heading2Style.CharacterFormat.Bold = true;

    // 自定义项目符号列表样式
    let bulletList = doc.Styles.Add({ listType: docModule.ListType.Bulleted, name: "bulletList" });
    doc.Styles.Add({ style: bulletList });

    // 应用样式:内置样式用 builtinStyle,自定义样式用名称
    let paragraph = sec.AddParagraph();
    paragraph.AppendText("季度运营报告");
    paragraph.ApplyStyle({ builtinStyle: docModule.BuiltinStyle.Title });

    paragraph = sec.AddParagraph();
    paragraph.AppendText("编制部门:运营管理部 | 编制日期:2026 年 9 月");
    paragraph.ApplyStyle({ builtinStyle: docModule.BuiltinStyle.Normal });

    paragraph = sec.AddParagraph();
    paragraph.AppendText("总体进展");
    paragraph.ApplyStyle({ builtinStyle: docModule.BuiltinStyle.Heading1 });

    paragraph = sec.AddParagraph();
    paragraph.AppendText("本季度三条产品线均按计划推进,整体交付节奏平稳。");
    paragraph.ApplyStyle({ builtinStyle: docModule.BuiltinStyle.Normal });

    paragraph = sec.AddParagraph();
    paragraph.AppendText("重点事项");
    paragraph.ApplyStyle({ builtinStyle: docModule.BuiltinStyle.Heading1 });

    paragraph = sec.AddParagraph();
    paragraph.AppendText("关键里程碑");
    paragraph.ApplyStyle({ builtinStyle: docModule.BuiltinStyle.Heading2 });

    paragraph = sec.AddParagraph();
    paragraph.AppendText("核心模块完成联调");
    paragraph.ListFormat.ApplyStyle("bulletList");

    paragraph = sec.AddParagraph();
    paragraph.AppendText("试运行阶段启动");
    paragraph.ListFormat.ApplyStyle("bulletList");

    paragraph = sec.AddParagraph();
    paragraph.AppendText("上线前评审排期");
    paragraph.ListFormat.ApplyStyle("bulletList");

    paragraph = sec.AddParagraph();
    paragraph.AppendText("资源投入");
    paragraph.ApplyStyle({ builtinStyle: docModule.BuiltinStyle.Heading2 });

    paragraph = sec.AddParagraph();
    paragraph.AppendText("当前团队人力较为紧张,建议在季度初完成排期确认。");
    paragraph.ListFormat.ApplyStyle("bulletList");

    // 定义输出文件名
    const outputFileName = "Styles-result.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={Styles}>开始</button>
    </div>
  );
}
export default App;

内置的 Title、Heading 1、Heading 2 被改写成统一的青色配色,自定义的 bulletList 则提供项目符号

修改内置样式并应用后的效果


在文档之间复制样式

企业里常有一份「样式母版」文档,新文档需要沿用它的样式。逐个手动重建样式既慢又容易漏,直接遍历源文档的 Styles 集合、把每个样式对象加入目标文档即可。

document.Styles 支持 Count 与 get_Item(index),因此可以按索引完整遍历。源文档的样式覆盖目标文档后,目标文档里原本引用这些样式名的段落就会立刻呈现出源文档的格式。

本例使用两个示例文档:CopyDocumentStyles1.docx 是带自定义样式的源文档,CopyDocumentStyles2.docx 是目标文档——它的部分段落引用了源文档才有的样式名,但自身并未定义,因此复制前这些段落按默认格式显示。

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

    // 将两个示例文件载入虚拟文件系统(VFS)
    let inputFileName_1 = "CopyDocumentStyles1.docx";
    await window.spire.FetchFileToVFS(inputFileName_1, "", `${process.env.PUBLIC_URL}static/data/`);

    let inputFileName_2 = "CopyDocumentStyles2.docx";
    await window.spire.FetchFileToVFS(inputFileName_2, "", `${process.env.PUBLIC_URL}static/data/`);

    // 加载源文档(带自定义样式)
    let srcDoc = new docModule.Document();
    srcDoc.LoadFromFile(inputFileName_1);

    // 加载目标文档(只有内置样式)
    let destDoc = new docModule.Document();
    destDoc.LoadFromFile(inputFileName_2);

    // 取源文档的样式集合
    let styles = srcDoc.Styles;

    // 逐个加入目标文档
    for (let i = 0; i < styles.Count; i++) {
      let style = styles.get_Item(i);
      destDoc.Styles.Add(style);
    }

    // 定义输出文件名
    const outputFileName = "CopyDocumentStyles_result.docx";

    // 保存文档到指定路径
    destDoc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
    destDoc.Dispose();
    srcDoc.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={CopyDocumentStyles}>开始</button>
    </div>
  );
}
export default App;

复制后目标文档获得了源文档的自定义样式,原本「引用了样式却不带格式」的段落恢复正常显示

复制样式前后的效果对比


常见问题

修改了 Normal 样式,但正文段落没有全部跟着变

原因:只有继承自 Normal 的样式才会受其影响。如果段落自带了直接格式(例如逐段设置了 CharacterFormat.FontName),直接格式的优先级高于样式,会盖住样式的设定。

解决:统一改用样式控制外观,去掉段落与 run 上的直接格式设置。Normal 常用来统一正文字体与字号:

let normalStyle = document.AddStyle({ builtinStyle: wasmModule.BuiltinStyle.Normal });
normalStyle.CharacterFormat.FontName = "微软雅黑";
normalStyle.CharacterFormat.FontSize = 11;

复制后目标文档的样式数量翻了一倍

原因:遍历时把源文档的全部样式都复制了过去,其中包含大量内置样式。这些样式名在目标文档里往往已经存在,逐个 Add 会形成重复条目。

解决:这就是该示例的实际行为(样式集合会明显膨胀)。实际项目中如果只需要自定义样式,建议先按名称过滤,只复制目标文档中尚不存在的样式:

for (let i = 0; i < srcDoc.Styles.Count; i++) {
  let style = srcDoc.Styles.get_Item(i);

  // 检查目标文档里是否已有同名样式
  let exists = false;
  for (let j = 0; j < destDoc.Styles.Count; j++) {
    if (destDoc.Styles.get_Item(j).Name === style.Name) {
      exists = true;
      break;
    }
  }

  // 只补充目标文档缺失的样式
  if (!exists) {
    destDoc.Styles.Add(style);
  }
}

自定义样式应用到段落时不生效

原因:ApplyStyle 对内置样式和自定义样式的调用方式不同——内置样式传 { builtinStyle } 对象,自定义样式传样式名字符串。传错形式时会静默忽略。

解决:按样式来源选择对应的调用形式:

// 内置样式
paragraph.ApplyStyle({ builtinStyle: wasmModule.BuiltinStyle.Heading1 });

// 自定义样式(字符串名,需与 Add 时的 name 一致)
paragraph.ListFormat.ApplyStyle("bulletList");

获取免费许可证

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

当一份 Word 文档要在多台设备、多种环境下流转时,字体是最容易出问题的环节:排版好的文档换台机器打开,字形、字号甚至分页都可能变样。稳妥的做法是先把文档里实际用到的字体盘点清楚,再把必须保留的字体随文档一起嵌入。Spire.Doc for JavaScript 基于 WebAssembly 在浏览器端直接完成这些操作,通过虚拟文件系统(VFS)管理输入输出文件,无需后端服务支持。

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

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


获取文档中使用的字体列表

获取字体列表的核心流程分为三个阶段:首先通过 FetchFileToVFS 将目标 Word 文档载入 WASM 虚拟文件系统;然后实例化 Document 加载文档,逐层遍历「节 → 段落 → 子对象」,从每个 TextRange 的 CharacterFormat 中取出字体名称、字号与文字颜色,并按三者组合去重;最后把结果拼成文本写入 VFS,读取回来后封装为 Blob 生成下载链接。

去重这一步值得留意:Map 的键必须是能按值比较的原始类型。如果把 { size, name } 这样的对象直接当作键,每次循环新建的对象都是不同的引用,Map 无法命中已有项,去重会完全失效。因此这里把字体名、字号、颜色拼成一个字符串来作键。

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

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

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

    // 以「字体名|字号|颜色」作为键去重
    const fontMap = new Map();

    // 遍历每一节
    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; k++) {
          const obj = paragraph.ChildObjects.get_Item(k);
          if (!(obj instanceof docModule.TextRange)) continue;

          const format = obj.CharacterFormat;
          const key = `${format.FontName}|${format.FontSize}|${format.TextColor.Name}`;

          fontMap.set(key, {
            name: format.FontName,
            size: format.FontSize,
            color: format.TextColor.Name
          });
        }
      }
    }

    // 拼接输出内容
    const lines = [];
    for (const font of fontMap.values()) {
      lines.push(`Font Name: ${font.name}, Size: ${font.size}, Color: ${font.color}`);
    }

    // 定义输出文件名并写入 VFS
    const outputFileName = "GetListOfUsingFonts_out.txt";
    window.dotnetRuntime.Module.FS.writeFile(outputFileName, lines.join("\n"));

    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], { type: 'text/plain' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);

    doc.Dispose();
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>获取Word文档中使用的字体列表</h1>
      <button onClick={GetListOfUsingFonts}>开始</button>
    </div>
  );
}
export default App;

统计字体列表后生成的文本文件内容

统计字体列表后生成的文本文件内容


嵌入私有字体

嵌入私有字体的核心流程同样分为三个阶段:首先通过 FetchFileToVFS 把待嵌入的字体文件连同文档一起载入 WASM 虚拟文件系统;然后实例化 Document 加载文档,在文档中写入一段使用该字体的文字,并把 EmbedFontsInFile 设为 true、调用 AddPrivateFont 注册字体文件的路径与注册名;最后保存文档并从 VFS 读取,封装为 Blob 后生成下载链接。

EmbedFontsInFile 与 AddPrivateFont 都是保存阶段才生效的配置,必须写在 SaveToFile 之前。PrivateFontPath 的第一个参数是字体在文档中的注册名,需要与 CharacterFormat.FontName 设置的值完全一致,否则 Word 找不到匹配项。

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

    // 将待嵌入的私有字体文件载入 VFS
    await window.spire.FetchFileToVFS("PT Serif Caption.ttf", "/Library/Fonts/", `${process.env.PUBLIC_URL}static/font/`);

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

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

    // 在第一节末尾追加一个段落,并套用私有字体
    const p = doc.Sections.get_Item(0).AddParagraph();
    const range = p.AppendText("Quarterly Operations Review");
    range.CharacterFormat.FontName = "PT Serif Caption";
    range.CharacterFormat.FontSize = 20;

    // 开启字体嵌入,并把私有字体文件注册到文档中
    doc.EmbedFontsInFile = true;
    doc.AddPrivateFont(new docModule.PrivateFontPath("PT Serif Caption", "PT Serif Caption.ttf"));

    // 定义输出文件名
    const outputFileName = "EmbedPrivateFont_out.docx";

    // 将文档保存到 VFS
    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={EmbedPrivateFont}>开始</button>
    </div>
  );
}
export default App;

嵌入私有字体后生成的文档效果

嵌入私有字体后生成的文档效果


常见问题

输出的字体列表里同一个字体重复出现很多次

原因:如果沿用「先用一个对象保存字体名与字号,再把这个对象当作 Map 的键」的写法,去重是不会生效的。JavaScript 中对象属于引用类型,Map 比较键时用的是引用相等而非值相等;每轮循环新建的对象都是全新引用,fontMap.has(font) 永远返回 false,于是文档中出现多少次 TextRange,列表里就会输出多少行,同一字体反复出现。

解决:改用原始类型作为键,把参与去重的字段拼成字符串(如 字体名|字号|颜色),保证内容相同的字体得到同一个键:

const format = obj.CharacterFormat;

// 用字符串作键:内容相同即视为同一项,去重才会生效
const key = `${format.FontName}|${format.FontSize}|${format.TextColor.Name}`;
fontMap.set(key, {
  name: format.FontName,
  size: format.FontSize,
  color: format.TextColor.Name
});

如果只需要字体名称这一层去重,用 Set 收集 format.FontName 即可。

字体已经嵌入,换台电脑打开却仍是替代字体

原因:常见的有三种情形。其一是调用时机不对——AddPrivateFont 只是把字体文件登记到内存中的文档对象上,真正写入 docx 的字体表与字体部件发生在保存阶段,因此 EmbedFontsInFile = true 和 AddPrivateFont(...) 都必须写在 SaveToFile 之前,漏掉 EmbedFontsInFile 则字体文件根本不会被写入。其二是字体名不匹配——PrivateFontPath 的第一个参数是文档中的注册名,必须与 CharacterFormat.FontName 的值完全一致(含空格与大小写),差一个字符就会匹配失败。其三是字体文件自身的嵌入许可——部分商用字体的 OS/2 表中 fsType 位禁止嵌入,Word 会直接忽略这类字体,此时只能更换字体或改用可嵌入的授权版本。

解决:把字体嵌入的全部配置集中放在保存之前,并保证注册名与字体名逐字一致:

// 字体名与注册名保持完全一致
const FONT_NAME = "PT Serif Caption";

const range = p.AppendText("Quarterly Operations Review");
range.CharacterFormat.FontName = FONT_NAME;
range.CharacterFormat.FontSize = 20;

// 开启嵌入并登记字体文件,两者都必须在 SaveToFile 之前
doc.EmbedFontsInFile = true;
doc.AddPrivateFont(new docModule.PrivateFontPath(FONT_NAME, "PT Serif Caption.ttf"));

doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });

获取免费许可证

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

Spire.Office 11.9.0 已正式发布。该版本新增了一些功能,如Spire.Doc 新增文档加载与导出控制类;Spire.XLS 新增支持 TRANSPOSE 公式。除此之外,一些在操作Word、Excel、PDF和PPT文档时出现的问题也已成功被修复。更多新功能及问题修复详情如下。

该版本涵盖了最新版的Spire.Doc、Spire.PDF、Spire.XLS、Spire.Presentation、Spire.DataExport、Spire.Barcode、Spire.DocViewer、Spire.PDFViewer、Spire.OfficeViewer、Spire.Email。

版本信息如下:


获取Spire.Office 11.9.0,请点击:

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

Spire.Doc

调整:

新功能:

问题修复:

Spire.XLS

新功能:

问题修复:

Spire.Presentation

新功能:

问题修复:

Spire.PDF

新功能:

问题修复:

调整:

Spire.Barcode

问题修复: