百家饭OpenAPI v0.7.0准备引入通过Swagger文件自动生成word的功能,因此研究和实现了基于Docx.js(https://docx.js.org/)生成文档的功能,也和大家分享一下使用这个库的一些经验。

为什么要用前端库

docx文件结构其实就是个xml,比上一代doc文件结构简单多了,上一代是基于ole2的文件格式,比较复杂,我们当时写开源的xls库的时候就很麻烦,整个文件是个二进制的结构体系。因此首先觉得docx前端处理应该问题不大。

其次一个问题是,golang好像没有特别好的doc库……,只查到了一个模板生成库,这个库还没有控制功能,不能首先循环控制等功能,我们要实现根据api定义生成文档,难度就比较大。还有一个虽然代码开源,但是使用需要在一个网站上申请key的玩意,一看就不怀好心。

所以想来想去还是用js库好了,至少还搜到了这个docx.js。

前端生成word的其他方法

百家饭OpenAPI在v0.6.0版本的时候,其实引入了一个叫清洁模式的功能,就是把文档中所有的功能按钮都删除了,这样,其实希望直接复制粘贴生成word。

主体其实是没有问题的,但是有两个问题:

1)使用css控制的样式失效

2)表格宽度出错

这是对应的网上的样式

 另外可以直接使用html代码生成word,只需要前面加上以下的对应的类型声明即可,我们的导出函数如下:


function Export2Word() {
  var preHtml =
    "<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'><head><meta charset='utf-8'><title>" +
    docTitle.value +
    "</title></head><body>";
  var postHtml = "</body></html>";
  var html = documentDiv.value.innerHTML + postHtml;

  var blob = new Blob(["\ufeff", html], {
    type: "application/msword",
  });

  // Specify link url
  var url =
    "data:application/vnd.ms-word;charset=utf-8," + encodeURIComponent(html);

  // Specify file name
  var filename = docTitle.value ? docTitle.value + ".doc" : "document.doc";

  // Create download link element
  var downloadLink = document.createElement("a");

  document.body.appendChild(downloadLink);

  if (navigator.msSaveOrOpenBlob) {
    navigator.msSaveOrOpenBlob(blob, filename);
  } else {
    // Create a link to the file
    downloadLink.href = url;

    // Setting the file name
    downloadLink.download = filename;

    //triggering the function
    downloadLink.click();
  }

  document.body.removeChild(downloadLink);
}

这个的同样问题是样式丢失,和复制粘贴一个效果,如果你要生成的内容样式比较简单,你在页面上也处理的很干净,没有表格和编号等样式,可以直接考虑。

Docx.js库的使用

然后我们就要开始介绍Docx.js库的使用了,使用效果还是很满意的。

Docx.js只需要引入以下库

yarn add docx

在需要使用该功能的页面引入docx库

import * as docx from "docx";

注意,不是直接import docx

生成文档

我们使用的是7.4.1版本,导出就是生成一个docx.Document对象,传入参数就是文档的整体定义,我们使用的情况如下

const doc = new docx.Document({
    //文档作者,显示在文档属性中
    creator: "百家饭OpenAPI为用户生成",
    //文档标题,显示在文档属性中
    title: docTitle.value,
    //文档介绍,显示在文档属性中
    description:,
    //文档中使用到的编号样式,稍后详细介绍
    numbering: {...},
    //文档中使用到的文字样式等,稍后详细介绍
    styles: {},
    //对应word中的节
    sections: [
      {
        properties: {},
        //页眉
        headers: {
            //默认页面,除了default,还可以通过first定义首页页面,通过even定义偶数页页眉
            default:...
        },
        //主体内容
        children: chidrenInDoc,
      },
    ],
  });

可以看出,对于页面内容的控制,基本都是通过这个选项表进行控制的,如果不是想开发一个完整功能的word编辑器,都可以沿用这个思路,

我们在搜索用法的时候,网上很多资料,包括官网资料都指向先定义一个元素,再使用setter函数进行各种编辑的方向,我们首先尝试有很多失败的地方,二是逻辑较为复杂,因此,我们还是建议,针对生成场景,都围绕这个选项表进行配置,一次性完成生成。 

定义内容

首先要做的工作就是根据页面内容逐一生成对应的docx里的组件,这里的组件包括各类标题、段落、列表、表格等。经测试,标题、段落、列表基本都对应docx.Paragraph对象,例如我们基本就是把innerHTML再变成docx.Paragraph,如果是标题,则指定heading参数,如果是列表就指定bullet参数

      return [
        new docx.Paragraph({
          text: item.childNodes[0].nodeValue,
          //如果是各级标题,就附加下面这行参数,是几级标题,就把6改成几
          heading: docx.HeadingLevel.HEADING_6,
          //如果是列表,就附加下面这个定义
          bullet: {
            level: 0, // How deep you want the bullet to be. Maximum level is 9
          },
        }),
      ];

解释一下,为什么我会返回数组,这是因为docx里面并没有html这样的层级管理,基本都是平铺Paragraph,例如下面的例子ul并不需要ul-li两级,只需要对应的两个li对应的带Bullet属性的Paragraph,所以我都是返回数组,再在上层进行拼接。

<ul>
  <li></li>
  <li></li>
</ul>

定义样式

样式是传入参数的styles部分,内容如下 

styles: {
      paragraphStyles: [
        {
          id: "Normal",
          name: "Normal",
          run: {
            font: props.displayOption.font,
          },
        },
        {
          id: "Heading1",
          name: "Heading 1",
          basedOn: "Normal",
          next: "Normal",
          quickFormat: true,
          run: {
            size: 32,
            bold: true,
          },
          paragraph: {
            alignment: docx.AlignmentType.CENTER,
            spacing: {
              before: 240,
              after: 120,
            },
          },
        },
        ...//更多样式
      ],
    },

可以看出styles主要是定义了文档中使用的各种样式,style的类型定义如下:

export interface IStylesOptions {
    readonly default?: IDefaultStylesOptions;
    readonly initialStyles?: BaseXmlComponent;
    readonly paragraphStyles?: IParagraphStyleOptions[];
    readonly characterStyles?: ICharacterStyleOptions[];
    readonly importedStyles?: (XmlComponent | StyleForParagraph | StyleForCharacter | ImportedXmlComponent)[];
}

我们主要定义了其中的paragraphStyles,至于其他几个类型的效果是什么样的,我们这次的工作没有涉及,大家可以尝试,这里只能说用paragraphStyles可以定义出样式来,这是没问题的。

而paragraphStyles的主要内容就是样式的数组,上面的例子可以看出,我们主要改了Normal(正文)的字体,然后定义了一个Heading 1(标题1)的样式,样式里定义了段落样式paragraph和文字样式run,其他的属性大家可以不用调整,有两个地方注意:

1)如果要定义标题样式,只能是Heading N,使用中文名称标题N,无效

2)next一般都是Normal,表示该段落之后的默认段落

这样,我们就可以定义出所有的标题样式出来,从Heading 1到Heading N就可以了。

定义多级序号

多级序号就是这种东西:

多级序号是单独定义在生成时传入的numbering里面,结构如下:

numbering: {
      config: [
        {
          reference: "ref1",
          levels: [
            {
              level: 0,//级别
              //该级别显示的文字样式
              format: docx.NumberFormat.DECIMAL,
              //具体的文字样式,使用%N来指代具体的某个级别的文字
              text: "%1",
              //编号后是Tab、空格还是无
              suffix: docx.LevelSuffix.SPACE,
              start: 1,
            },
            {
              level: 1,
              format: docx.NumberFormat.DECIMAL,
              text: "%1.%2",
              suffix: docx.LevelSuffix.SPACE,
              start: 1,
            },
            {
              level: 2,
              format: docx.NumberFormat.DECIMAL,
              text: "%1.%2.%3",
              suffix: docx.LevelSuffix.SPACE,
              start: 1,
            },
            {
              level: 3,
              format: docx.NumberFormat.DECIMAL,
              text: "%1.%2.%3.%4",
              suffix: docx.LevelSuffix.SPACE,
              start: 1,
            },
            {
              level: 4,
              format: docx.NumberFormat.DECIMAL,
              text: "%1.%2.%3.%4.%5",
              suffix: docx.LevelSuffix.SPACE,
              start: 1,
            },
          ],
        },
      ],
    },

 numbering只有1个config参数,config里面主要要定义两个东西,一个是reference,就是给这个编号取个名字,其他地方引用的时候就能按名称找到,另外就是一个levels数组了,里面从0开始定义了多个级别的编号,每个编号的内容参考上面的注释说明,需要注意的是,虽然我们的level是从0开始的,但是在text中要引用这个级别的时候,却是从1开始的(可能和start的取值有关系,但是我们没有找到合适的文档,所以这个地方存疑)。

另外对format进行一下说明,这是format的枚举参数值:

export declare enum NumberFormat {
    DECIMAL = "decimal",
    UPPER_ROMAN = "upperRoman",
    LOWER_ROMAN = "lowerRoman",
    UPPER_LETTER = "upperLetter",
    LOWER_LETTER = "lowerLetter",
    ORDINAL = "ordinal",
    CARDINAL_TEXT = "cardinalText",
    ORDINAL_TEXT = "ordinalText",
    HEX = "hex",
    CHICAGO = "chicago",
    IDEOGRAPH_DIGITAL = "ideographDigital",
    JAPANESE_COUNTING = "japaneseCounting",
    AIUEO = "aiueo",
    IROHA = "iroha",
    DECIMAL_FULL_WIDTH = "decimalFullWidth",
    DECIMAL_HALF_WIDTH = "decimalHalfWidth",
    JAPANESE_LEGAL = "japaneseLegal",
    JAPANESE_DIGITAL_TEN_THOUSAND = "japaneseDigitalTenThousand",
    DECIMAL_ENCLOSED_CIRCLE = "decimalEnclosedCircle",
    DECIMAL_FULL_WIDTH_2 = "decimalFullWidth2",
    AIUEO_FULL_WIDTH = "aiueoFullWidth",
    IROHA_FULL_WIDTH = "irohaFullWidth",
    DECIMAL_ZERO = "decimalZero",
    BULLET = "bullet",
    GANADA = "ganada",
    CHOSUNG = "chosung",
    DECIMAL_ENCLOSED_FULL_STOP = "decimalEnclosedFullstop",
    DECIMAL_ENCLOSED_PAREN = "decimalEnclosedParen",
    DECIMAL_ENCLOSED_CIRCLE_CHINESE = "decimalEnclosedCircleChinese",
    IDEOGRAPH_ENCLOSED_CIRCLE = "ideographEnclosedCircle",
    IDEOGRAPH_TRADITIONAL = "ideographTraditional",
    IDEOGRAPH_ZODIAC = "ideographZodiac",
    IDEOGRAPH_ZODIAC_TRADITIONAL = "ideographZodiacTraditional",
    TAIWANESE_COUNTING = "taiwaneseCounting",
    IDEOGRAPH_LEGAL_TRADITIONAL = "ideographLegalTraditional",
    TAIWANESE_COUNTING_THOUSAND = "taiwaneseCountingThousand",
    TAIWANESE_DIGITAL = "taiwaneseDigital",
    CHINESE_COUNTING = "chineseCounting",
    CHINESE_LEGAL_SIMPLIFIED = "chineseLegalSimplified",
    CHINESE_COUNTING_TEN_THOUSAND = "chineseCountingThousand",
    KOREAN_DIGITAL = "koreanDigital",
    KOREAN_COUNTING = "koreanCounting",
    KOREAN_LEGAL = "koreanLegal",
    KOREAN_DIGITAL_2 = "koreanDigital2",
    VIETNAMESE_COUNTING = "vietnameseCounting",
    RUSSIAN_LOWER = "russianLower",
    RUSSIAN_UPPER = "russianUpper",
    NONE = "none",
    NUMBER_IN_DASH = "numberInDash",
    HEBREW_1 = "hebrew1",
    HEBREW_2 = "hebrew2",
    ARABIC_ALPHA = "arabicAlpha",
    ARABIC_ABJAD = "arabicAbjad",
    HINDI_VOWELS = "hindiVowels",
    HINDI_CONSONANTS = "hindiConsonants",
    HINDI_NUMBERS = "hindiNumbers",
    HINDI_COUNTING = "hindiCounting",
    THAI_LETTERS = "thaiLetters",
    THAI_NUMBERS = "thaiNumbers",
    THAI_COUNTING = "thaiCounting",
    BAHT_TEXT = "bahtText",
    DOLLAR_TEXT = "dollarText"
}

非常的多,包含了很多地区性的文字样式,和中国相关的有

    CHINESE_COUNTING = "chineseCounting",
    CHINESE_LEGAL_SIMPLIFIED = "chineseLegalSimplified",
    CHINESE_COUNTING_TEN_THOUSAND = "chineseCountingThousand",

具体的我没有测试了,大家可以尝试更换这几个来达到输出中文数字的作用。

实现导出

文档生成好之后,导出的方式是先生成blob,再下载到本地,我们的代码如下


  docx.Packer.toBlob(doc).then((blob) => {
    // Specify link url
    const blobUrl = URL.createObjectURL(blob);

    // Specify file name
    var filename = xxx

    // Create download link element
    var downloadLink = document.createElement("a");

    document.body.appendChild(downloadLink);

    if (navigator.msSaveOrOpenBlob) {
      navigator.msSaveOrOpenBlob(blob, filename);
    } else {
      // Create a link to the file
      downloadLink.href = blobUrl;

      // Setting the file name
      downloadLink.download = filename;

      //triggering the function
      downloadLink.click();
    }

    document.body.removeChild(downloadLink);
  });

使用评价

docx.js库很好的解决了我们生成docx文档的问题,生成性能也不错,还把工作转移到了客户端,我们很满意,但是这个库目前来看文档还是不全,没有很好的入口教材可以使用,希望作者能加强一下。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐