深入在線文檔系統的 MarkDown/Word/PDF 導出能力設計

深入在線文檔系統的 MarkDown/Word/PDF 導出能力設計

當我們實現在線文檔的系統時,通常需要考慮到文檔的導出能力,特別是對於私有化部署的複雜ToB產品來說,文檔的私有化版本交付能力就顯得非常重要,此外成熟的在線文檔系統還有很多複雜的場景,都需要我們提供文檔導出的能力。那麼本文就以Quill富文本編輯器引擎爲基礎,探討文檔導出爲MarkDownWordPDF插件化設計實現。

文章中我們即將要聊到的每個轉換器設計都有相關示例https://github.com/WindrunnerMax/QuillBlocks/tree/master/examples,在實現DEMO的過程中也是踩了很多坑,給予的示例可以完成純前端的數據轉換,也可以通過Node來實現,還是比較有參考價值的。

  • delta-set.ts: 數據轉換格式轉換,從扁平數據結構轉換到嵌套結構。
  • delta-to-md.ts: 將文檔數據結構轉換爲Markdown,輸出爲純文本結構。
  • delta-to-word.ts: 將文檔數據結構轉換爲docx文件,輸出直接寫入當前目錄。
  • delta-to-word.html: 文檔數據轉換docx文件的HTML版本,可直接在瀏覽器編寫文檔並下載word文件。
  • delta-to-pdf.ts: 將文檔數據結構轉換爲PDF文件,輸出直接寫入當前目錄。
  • delta-to-pdf.html: 文檔數據轉換PDF文件的HTML版本,可直接在瀏覽器編寫文檔並下載PDF文件。
  • pdf-with-outline.ts: 將存量PDF文件寫入大綱Outline,作爲輸出PDF能力的補充,輸出直接寫入當前目錄。

週末兩天時間鍵盤都搓出火星子了才寫完這篇文章,我覺得還是很有必要一鍵三連一下的,手動狗頭。

描述

前段時間有位朋友跟我講了個有趣的事,他們公司的某個B端大客戶提了個需求,需要支持在他們的在線文檔系統中直接支持遠程連接打印機來打印文檔,理由非常充分,就是他們公司的大老闆不喜歡盯着電腦屏幕看文檔,而是希望能夠閱讀紙質版的文檔,爲了不失去這家大客戶就必須高優支持這個能力,當然這也確實是一個完整的在線文檔SaaS系統所需要支持的能力。

雖然我們的在線文檔主要是以SaaS提供服務的,但是同樣我們也可以作爲PaaS平臺來提供服務,實際上這樣的場景也比較明確,例如我們的文檔系統存儲的數據結構通常都是自定義的數據結構,當用戶想通過本地生成MarkDown模版的方式進行初始化文檔內容時,我們就需要提供導入的能力,此時如果用戶又想將文檔轉換爲MarkDown模版,我們通常就又需要導出的能力,還有跨平臺的數據遷移或者合作時,通常就需要我們通過OpenAPI提供各種各樣數據轉換的能力,而本質上還是基於我們的數據結構設計的一套轉換系統。

回到數據轉換能力本身,我們實際上可以以某種通用的數據結構類型爲基準,在此基準上進行各種數據格式的轉換,在我們的文檔系統中,成本最小的通用數據結構就是HTML,我們可以以HTML爲基準進行數據轉換,並且有很多開源的實現可以參考。通過這種思路實現的數據轉換是成本比較低的,但是效率上就沒有那麼高了,所以我們在這裏聊的還是從我們的基準數據結構DSL - Domain Specific Language來進行數據轉換,quill-delta的數據結構是設計的非常棒的扁平化富文本描述DSL,所以本文就以quill-delta的數據結構設計來聊聊數據轉換導出。並且我們在設計轉換模型的時候,需要考慮到插件化的設計,因爲我們不能夠保證文檔系統後邊不會擴展塊類型,所以這個設計思想是非常有必要的。

MarkDown

在工作中我們可能會遇到類似的場景,用戶希望將在線文檔嵌入到產品本身的站點中,作爲API文檔或者幫助中心的文檔使用,而由於成本的關係,這些幫助中心大都是基於MarkDown搭建的,畢竟維護一款富文本產品成本相當之高,那麼作爲PaaS產品我們就需要提供數據轉換的能力,當然提供SDK直接渲染我們的數據結構也可以是我們的產品能力,但是在很多情況下是比較難以投入人力做文檔渲染遷移的,所以直接通過數據轉換是最低成本的方式。

實際上各種產品文檔慢慢從MarkDown遷移到富文本是趨勢所在,作爲研發我們使用MarkDown來編寫文檔是比較比較常見的,所以最開始各個產品使用MD渲染器搭建是合理的,但是隨着隨着產品的迭代和用戶的不斷增加,運營團隊與專業TW團隊介入進來,特別是海內外都要維護的產品,就更需要運營與TW團隊支持,而此時我們可能只是完成初稿的編寫,而後續的維護與更新就需要運營團隊來維護,而運營團隊通常不會使用MD來編寫文檔,特別是文檔站如果是使用Git來管理的話,就更加難以接受了,所以對於類似的情況所見即所得在線文檔產品就比較重要,而維護一款在線文檔產品的成本是非常高的,那麼大部分團隊都可能會選擇接入文檔中臺,由此上邊我們提到的能力都變的非常重要了。

當然,作爲在線文檔的PaaS不光要提供數據轉換到MD的能力,從MD導入的能力同樣也是非常重要的,這裏也有比較常見的場景,除了上邊我們提到的用戶可能是使用MD來編寫文檔模版並且導入到文檔系統之外,還有已經上線的產品暫時並沒有配置運營團隊,而就是使用MD來編寫文檔,而這些產品的文檔是使用我們提供的文檔SDK渲染器來提供的,都需要統一走我們的PaaS平臺來更新文檔內容,所以這種場景下數據轉換爲我們的DSL又比較重要了,實際上如果將我們定位爲PaaS產品的話,就是要不斷兼容各種場景與系統,更加類似於中臺的概念,當然本文就不太涉及數據導入的能力,我們還是主要關注於數據正向轉出的方案。

那麼此時我們正式開始數據到MD的轉換,首先我們需要想到一個問題,各種MD解析器對於語法的支持程度是不一樣的,例如最基本的換行,有些解析器對於單個回車就會解析爲段落,而有些解析器必須要有兩個空格加回車或者兩個回車才能正常解析爲段落,所以爲了兼容類似的情況,我們的插件化設計就必不可少。那麼緊接着我們思考第二個問題,MD畢竟是輕量級的格式描述,而我們的DSL是複雜的格式描述,我們的塊結構種類是非常多的,所以我們還需要HTML來輔助我們進行復雜格式的轉換。那麼問題又來了,爲什麼我們不直接將其轉換爲HTML而是要混着MD格式呢,實際上這也是爲了兼容性考慮,用戶的MD可能組合了不同的插件,用HTML組合的話樣式會有差異,複雜的樣式組合起來會比較麻煩,特別是需要藉助mixin-react類似MDX實現的方式,所以我們還是選擇MD作爲基準HTML作爲輔助來實現數據轉換。

前邊我們已經提到了我們的塊是比較複雜的,並且實際上是會存在很多嵌套結構,對應到HTML就類似於表格中嵌套了代碼塊的格式,而quill-delta的數據結構是扁平化的,所以我們也需要將其轉換爲方便處理的嵌套結構,而如果是完整的樹形結構轉換的複雜度就會就會比較高,所以我們採取一種折中的方案,在外部包裹一層Map結構,通過key的方式取得目標delta結構的數據,由此在數據獲取的時候可以動態構成嵌套結構。

// 用於對齊渲染時的數據表達
// 同時爲了方便處理嵌套關係 將數據結構拍平
class DeltaSet {
  private deltas: Record<string, Line[]> = {};

  get(zoneId: string) {
    return this.deltas[zoneId] || null;
  }

  push(id: string, line: Line) {
    if (!this.deltas[id]) this.deltas[id] = [];
    this.deltas[id].push(line);
  }
}

同時,我們需要選取處理數據的基準,而我們的文檔實際上就是由段落格式與行內格式組成,那麼很明顯我們就可以將其拆分爲兩部分,行格式與行內格式,映射到delta中就相當於Line嵌套了Ops並且攜帶了本身的行格式例如標題、對齊等,實際上加上我們的DeltaSet結構就是分爲了三部分來描述我們初步處理希望轉換到的數據結構。

const ROOT_ZONE = "ROOT";
const CODE_BLOCK_KEY = "code-block";
type Line = {
  attrs: Record<string, boolean | string | number>;
  ops: Op[];
};
const opsToDeltaSet = (ops: Op[]) => {
  // 構造`Delta`實例
  const delta = new Delta(ops);
  // 將`Delta`轉換爲`Line`的數據表達
  const group: Line[] = [];
  delta.eachLine((line, attributes) => {
    group.push({ attrs: attributes || {}, ops: line.ops });
  });
  // ...
}

對於DeltaSet我們需要定義入口Zone,在這裏也就是"ROOT"標記的delta結構,而在DEMO中我們只定義了CodeBlock的塊級嵌套結構,所以在下面的示例中我們只處理了代碼塊的數據嵌套表達,因爲原本的數據結構是扁平的,我們就需要處理一些邊界條件,也就是代碼塊結構的起始與結束,當遇到代碼塊結構時,將正在處理的Zone指向爲新的delta塊,並且需要在原本的結構中建立一個指向關係,在這裏是通過op中指定zoneId標識符來實現的,在結束的時候將指針恢復到之前的Zone目標。當然通常我們還需要處理多層嵌套的塊,這裏只是簡單的處理了一層嵌套,多層嵌套的情況下就需要用藉助棧來處理,這裏就不再展開了。

const deltaSet = new DeltaSet();
// 標記當前正在處理的的`ZoneId`
// 實際情況下可能會存在多層嵌套 此時需要用`stack`來處理
let currentZone: string = ROOT_ZONE;
// 標記當前處理的類型 如果存在多種類型時會用得到
let currentMode: "NORMAL" | "CODEBLOCK" = "NORMAL";
// 用於判斷當前`Line`是否爲`CodeBlock`
const isCodeBlockLine = (line: Line) => line && !!line.attrs[CODE_BLOCK_KEY];
// 遍歷`Line`的數據表達 構造`DeltaSet`
for (let i = 0; i < group.length; ++i) {
  const prev = group[i - 1];
  const current = group[i];
  const next = group[i + 1];
  // 代碼塊結構的起始
  if (!isCodeBlockLine(prev) && isCodeBlockLine(current)) {
    const newZoneId = getUniqueId();
    // 存在嵌套關係 構造新的索引
    const codeBlockLine: Line = {
      attrs: {},
      ops: [{ insert: " ", attributes: { [CODE_BLOCK_KEY]: "true", zoneId: newZoneId } }],
    };
    // 需要在當前`Zone`加入指向新`Zone`的索引`Line`
    deltaSet.push(currentZone, codeBlockLine);
    currentZone = newZoneId;
    currentMode = "CODEBLOCK";
  }
  // 將`Line`置入當前要處理的`Zone`
  deltaSet.push(currentZone, group[i]);
  // 代碼塊結構的結束
  if (currentMode === "CODEBLOCK" && isCodeBlockLine(current) && !isCodeBlockLine(next)) {
    currentZone = ROOT_ZONE;
    currentMode = "NORMAL";
  }
}

現在數據已經準備好了,我們就需要設計整個轉換系統了,前邊我們已經提到了整個轉換器是由兩種類型組成的,所以我們的插件系統也就分爲了兩部分,而實際上對於MD來說,本質上就是字符串拼接,所以對於插件的輸出主要就是字符串了,此時需要注意一個問題,同一個Op描述可能會有多個格式,例如某個塊可能是加粗與斜體的組合,此時我們的格式是由兩個插件分別處理的,那麼這樣的話就不能在插件中直接輸出結果,而是需要通過prefixsuffix的方式拼接,同樣的對於行格式也是如此,特別是需要HTML標籤來輔助表達的情況下。此外,有時候我們可能會明確節點不會存在嵌套的情況,例如圖片的格式,那麼此時就可以通過last標識符來標記最後一個節點,由此避免多餘的檢查。

type Output = {
  prefix?: string;
  suffix?: string;
  last?: boolean;
};

由於存在需要HTML輔助的節點,而我們迭代的方式非常類似於遞歸拼接字符串的方式,所以我們需要穿插一個標識符,標識此時需要解析成HTML而不是MD標記,例如此時我們匹配到行節點是居中的,那麼此時該行內部所有的節點都需要解析成HTML標記,而且要注意的是這個標記在每次行迭代開始前都需要重置,避免前邊的內容對後邊的內容造成影響。

type Tag = {
  isHTML?: boolean;
  isInZone?: boolean;
};

對於插件的類型的輸入部分主要是在迭代的時候將相鄰的描述一併傳遞,這對於處理列表的格式非常有用,很多MD解析器是需要列表的前後都需要額外空行的,對於行內格式的合併也是非常有用的,可以避免描述塊產生多個標記。此外,我們需要對插件設置唯一的標識,前邊提到了我們是需要對多種場景進行兼容的,在實際處理插件的時候就可以按照實例化的順序覆蓋處理,設置插件的優先級也是很有必要的,例如引用與列表疊加的行格式,引用格式需要在列表前解析才能正確展示樣式。

type LineOptions = {
  prev: Line | null;
  current: Line;
  next: Line | null;
  tag: Tag;
};
type LinePlugin = {
  key: string; // 插件重載
  priority?: number; // 插件優先級
  match: (line: Line) => boolean; // 匹配`Line`規則
  processor: (options: LineOptions) => Promise<Omit<Output, "last"> | null>; // 處理函數
};
type LeafOptions = {
  prev: Op | null;
  current: Op;
  next: Op | null;
  tag: Tag;
};
type LeafPlugin = {
  key: string; // 插件重載
  priority?: number; // 插件優先級
  match: (op: Op) => boolean; // 匹配`Op`規則
  processor: (options: LeafOptions) => Promise<Output | null>; // 處理函數
};

接下來是入口的處理函數,首先我們需要處理行格式,因爲行內格式可能會因爲行格式出現不同的結果,例如居中的行格式會導致行內格式解析成HTML標記,這個標記是通過可變的tag對象來實現的,我們的行格式是有可能會匹配到多個插件的,所有的結果都應該保存起來,同樣的對於行內格式也是如此,在處理函數的最後,我們將結果拼接爲字符串即可。

const parseZoneContent = async (
  zoneId: string,
  options: { defaultZoneTag?: Tag; wrap?: string }
): Promise<string | null> => {
  const { defaultZoneTag = {}, wrap: cut = "\n\n" } = options;
  const lines = deltaSet.get(zoneId);
  if (!lines) return null;
  const result: string[] = [];
  for (let i = 0; i < lines.length; ++i) {
    // ... 取行數據
    const prefixLineGroup: string[] = [];
    const suffixLineGroup: string[] = [];
    // 不能影響外部傳遞的`Tag`
    const tag: Tag = { ...defaultZoneTag };
    // 先處理行內容 // 需要先處理行格式
    for (const linePlugin of LINE_PLUGINS) {
      if (!linePlugin.match(currentLine)) continue;
      // ... 執行插件
      if (!result) continue;
      result.prefix && prefixLineGroup.push(result.prefix);
      result.suffix && suffixLineGroup.push(result.suffix);
    }
    const ops = currentLine.ops;
    // 處理節點內容
    for (let k = 0; k < ops.length; ++k) {
      // ... 取節點數據
      const prefixOpGroup: string[] = [];
      const suffixOpGroup: string[] = [];
      let last = false;
      for (const leafPlugin of LEAF_PLUGINS) {
        if (!leafPlugin.match(currentOp)) continue;
        // ... 執行插件
        if (!result) continue;
        result.prefix && prefixOpGroup.push(result.prefix);
        result.suffix && suffixOpGroup.unshift(result.suffix);
        if (result.last) {
          last = true;
          break;
        }
      }
      // 如果沒有匹配到`last`則需要默認加入節點內容
      if (!last && currentOp.insert && isString(currentOp.insert)) {
        prefixOpGroup.push(currentOp.insert);
      }
      prefixLineGroup.push(prefixOpGroup.join("") + suffixOpGroup.join(""));
    }
    result.push(prefixLineGroup.join("") + suffixLineGroup.join(""));
  }
  return result.join(cut);
};

那麼有了調度器,我們接下來只需要關注插件的實現,在這裏以標題插件爲例實現轉換邏輯,實際上這部分邏輯非常簡單,只需要解析LineAttributes來決定返回值就可以了。

const HeadingPlugin: LinePlugin = {
  key: "HEADING",
  match: line => !!line.attrs.header,
  processor: async options => {
    if (options.tag.isHTML) {
      options.tag.isHTML = true;
      return {
        prefix: `<h${options.current.attrs.header}>`,
        suffix: `</h${options.current.attrs.header}>`,
      };
    } else {
      const repeat = Number(options.current.attrs.header);
      return { prefix: "#".repeat(repeat) + " " };
    }
  },
};

對於行內的插件也是類似的邏輯,在這裏以加粗插件爲例實現轉邏輯,同樣也是僅需要判斷OpAttributes來決定返回值即可。

const BoldPlugin: LeafPlugin = {
  key: "BOLD",
  match: op => op.attributes && op.attributes.bold,
  processor: async options => {
    if (options.tag.isHTML) {
      options.tag.isHTML = true;
      return { prefix: "<strong>", suffix: "</strong>" };
    } else {
      return { prefix: "**", suffix: "**" };
    }
  },
};

https://github.com/WindrunnerMax/QuillBlocks/blob/master/examples/中有完整的DeltaSet數據轉換delta-set.tsMarkDown數據轉換delta-to-md.ts,可以通過ts-node來執行測試,實際上我們可能也注意到了,這個調度器不僅可以轉換MD格式,實際上還可以進行完整的HTML格式轉換,那麼既然HTML轉換邏輯有了,我們就有了非常通用的中間產物來生成各種文件了,並且如果將插件改裝成同步的模式,這個方案還可以用來處理在線文檔的複製行爲,實際的用途就非常豐富了。此外,在實際使用的過程中對於插件的單測是非常有必要的,在開發的時候就應該就測試用例全部積累起來,用以避免改動所造成的未知問題,特別是當多個插件組合的時候,兼容的業務場景一旦複雜起來,對於各種case的處理就會變的尤爲重要,特別是全量同步更新的場景下,積累邊界的測試用例就變得更加重要。

Word

在前邊我們聊了作爲PaaS平臺的數據轉換兼容能力,而作爲SaaS平臺直接生成交付文檔是必不可少的能力,特別是在產品需要私有化部署以及提供多版本線上能力的時候。Word是最常見的文檔交付格式之一,特別是在需要導出後再次修改的情況下生成Word文檔就變得非常有用,所以在本節我們就來聊一下如何生成Word格式的交付文檔。

OOXMLOffice Open XML是微軟在Office 2007中提出的一種新的文檔格式,Office 2007中的WordExcelPowerPoint默認均採用OOXML格式,OOXML同樣也成爲了ECMA規範的一部分,編號爲ECMA-376。實際上對於現在的Word文檔,我們可以直接將其解壓從而得到封裝的數據,將其擴展名修改爲zip之後,就可以得到內部的文件,下面是docx文件中的部分組成。

  • [Content_Types].xml: 用於定義裏面每個文件的內容類型,例如可以標記一個文件是圖片.jpg還是文本內容.xml
  • _rels: 通常會存在.rels文件,用以保存各個Part之間的關係,用來描述不同文件之間的關聯,例如某文本與圖片存在關聯。
  • docProps: 其中存放了整個word文檔的屬性信息,如作者、創建時間、標籤等。
  • word: 存儲的是文檔的主要內容,包括文本、圖片、表格以及樣式等。
    • document.xml: 保存了所有的文本以及對文本的引用。
    • styles.xml: 保存了文檔中所有使用到的樣式。
    • theme.xml: 保存了應用於文檔的主題設置。
    • media: 保存了文檔中使用的所有媒體文件,如圖片。

看到這些描述我們可能會非常迷茫應該如何真正組裝成word文件,畢竟這裏有如此多複雜的關係描述。那麼既然我們不能瞬間瞭解整個docx文件的構成,我們還是可以藉助於框架來生成docx文件的,在調研了一些框架後,我發現大概有兩種生成方式,一種就是我們常說的通過通用的HTML格式來生成,例如html-docx-jshtml-to-docxpandoc,還有一種是代碼直接控制生成,相當於減少了轉HTML這一步,例如officegendocx。在觀察到很多庫實際上很多年沒有過更新了,並且在這裏我們更希望直接輸出docx,而不是需要HTML中轉,畢竟在線文檔的交付對於格式還是需要有比較高的控制能力的,綜上最後選擇使用docx來生成word文件。

docx幫我們簡化了整個word文件的生成過程,通過構建內建對象的層級關係,我們就可以很方便的生成出最後的文件,並且無論是在Node環境還是瀏覽器環境中都可以運行,所以在本節的DEMO中會有Node和瀏覽器兩個版本的DEMO。那麼現在我們就以Node版本爲例聊聊如何生成word文件,首先我們需要定義樣式,在word中有一個稱作樣式窗格的模塊,我們可以將其理解爲CSSclass,這樣我們就可以在生成文檔的時候直接引用樣式,而不需要在每個節點中都定義一遍樣式。

const PAGE_SIZE = {
  WIDTH: sectionPageSizeDefaults.WIDTH - 1440 * 2,
  HEIGHT: sectionPageSizeDefaults.HEIGHT - 1440 * 2,
};
const DEFAULT_FORMAT_TYPE = {
  H1: "H1",
  H2: "H2",
  CONTENT: "Content",
  IMAGE: "Image",
  HF: "HF",
};
// ... 基本配置
const PRESET_SCHEME_LIST: IParagraphStyleOptions[] = [
  {
    id: DEFAULT_FORMAT_TYPE.CONTENT,
    name: DEFAULT_FORMAT_TYPE.CONTENT,
    quickFormat: true,
    paragraph: {
      spacing: DEFAULT_LINE_SPACING_FORMAT,
    },
  },
  // ... 預設格式
]

緊接着我們需要處理單位的轉換,在我們使用word的時候可能會注意到我們的單位都是磅值PT,而在我們的瀏覽器中通常是PX,因爲在DEMO中我們僅涉及到了圖片大小的處理,其他的都是直接使用DAX與比例的方式實現的,所以在這裏只是列舉了用到的單位轉換。

const daxToCM = (dax: number) => (dax / 20 / 72) * 2.54;
const cmToPixel = (cm: number) => cm * 10 * 3.7795275591;
const daxToPixel = (dax: number) => Math.ceil(cmToPixel(daxToCM(dax)));

與轉換MD類似,我們同樣需要定義轉換調度的邏輯,但是有一點不一樣的是MD中輸出是字符串,我們的可操作性很大,在docx中是有嚴格的對象結構關係的,所以在這裏我們需要嚴格定義行與行內的類型關係,並且傳遞的Tag需要有更多的內容。

type LineBlock = Table | Paragraph;
type LeafBlock = Run | Table | ExternalHyperlink;
type Tag = {
  width: number;
  fontSize?: number;
  fontColor?: string;
  spacing?: ISpacingProperties;
  paragraphFormat?: string;
  isInZone?: boolean;
  isInCodeBlock?: boolean;
};

插件的輸入設計與MD類似,但是輸出的內容就需要更加嚴格,行內元素的插件輸出必須是行內的對象類型,行元素的插件輸出必須要是行對象類型,特別要注意的是在行插件中,我們傳遞了leaves參數,這裏也就意味着此時我們的行內元素與行元素的調度是由行插件來管理,而不是在外部Zone調度模塊來管理。

type LeafOptions = {
  prev: Op | null;
  current: Op;
  next: Op | null;
  tag: Tag;
};
type LeafPlugin = {
  key: string; // 插件重載
  priority?: number; // 插件優先級
  match: (op: Op) => boolean; // 匹配`Op`規則
  processor: (options: LeafOptions) => Promise<LeafBlock | null>; // 處理函數
};
type LineOptions = {
  prev: Line | null;
  current: Line;
  next: Line | null;
  tag: Tag;
  leaves: LeafBlock[];
};
type LinePlugin = {
  key: string; // 插件重載
  priority?: number; // 插件優先級
  match: (line: Line) => boolean; // 匹配`Line`規則
  processor: (options: LineOptions) => Promise<LineBlock | null>; // 處理函數
};

接下來就是入口的Zone調度函數,這裏與之前的MD調度不同,我們需要首先處理葉子節點也就是行內樣式,因爲這裏有一個特別需要關注的點是Paragraph對象是不能包裹Table對象的,而此時如果我們需要實現一個塊級結構那麼外部是需要包裹Table而不是Paragraph,也就是說此時我們的行內元素內容是會決定行元素的格式,即A影響B那就先處理A,所以此時是先處理行內元素,並且單個塊結構僅會匹配到一個插件,所以相關的通用內容處理是需要封裝到通用函數中的。

const parseZoneContent = async (
  zoneId: string,
  options: { defaultZoneTag?: Tag }
): Promise<LineBlock[] | null> => {
  const { defaultZoneTag = { width: PAGE_SIZE.WIDTH } } = options;
  const lines = deltaSet.get(zoneId);
  if (!lines) return null;
  const target: LineBlock[] = [];
  for (let i = 0; i < lines.length; ++i) {
    // ... 取行數據
    // 不能影響外部傳遞的`Tag`
    const tag: Tag = { ...defaultZoneTag };
    // 處理節點內容
    const ops = currentLine.ops;
    const leaves: LeafBlock[] = [];
    for (let k = 0; k < ops.length; ++k) {
      // ... 取節點數據
      const hit = LEAF_PLUGINS.find(leafPlugin => leafPlugin.match(currentOp));
      if (hit) {
        // ... 執行插件
        result && leaves.push(result);
      }
    }
    // 處理行內容
    const hit = LINE_PLUGINS.find(linePlugin => linePlugin.match(currentLine));
    if (hit) {
      // ... 執行插件
      result && target.push(result);
    }
  }
  return target;
};

接下來同樣的我們需要定義插件,這裏以文本插件爲例實現轉換邏輯,因爲基本的文本樣式都封裝在TextRun這個對象中,所以我們只需要處理TextRun對象的屬性即可,當然對於其他的Run類型對象例如ImageRun等,我們還是需要單獨定義插件處理的。

const TextPlugin: LeafPlugin = {
  key: "TEXT",
  match: () => true,
  processor: async (options: LeafOptions) => {
    const { current, tag } = options;
    if (!isString(current.insert)) return null;
    const config: WithDefaultOption<IRunOptions> = {};
    config.text = current.insert;
    const attrs = current.attributes || {};
    if (attrs.bold) config.bold = true;
    if (attrs.italic) config.italics = true;
    if (attrs.underline) config.underline = {};
    if (tag.fontSize) config.size = tag.fontSize;
    if (tag.fontColor) config.color = tag.fontColor;
    return new TextRun(config);
  },
};

對於行類型的插件,我們以段落插件爲例實現轉換邏輯,對於段落插件是當匹配不到其他段落格式時需要最終併入的插件。前邊我們提到的Paragraph對象是不能包裹Table元素的問題也需要在此處處理,因爲我們的塊級表達就是藉助Table對象實現的,那麼如果葉子節點沒有匹配到塊元素,則直接返回段落元素即可,如果匹配到了塊元素且僅有單個元素,那麼將其直接提升並返回即可,如果匹配到塊元素且還有其他元素,那麼此時就需要將所有的元素包裹一層塊元素再返回,實際上這部分邏輯應該封裝起來爲所有的行級元素插件共同調用來兼容解析,否則層級嵌套出現問題的話生成的word是無法打開的。

const ParagraphPlugin: LinePlugin = {
  key: "PARAGRAPH",
  match: () => true,
  processor: async (options: LineOptions) => {
    const { leaves, tag } = options;
    const config: WithDefaultOption<IParagraphOptions> = {};
    const isBlockNode = leaves.some(leaf => leaf instanceof Table);
    config.style = tag.paragraphFormat || DEFAULT_FORMAT_TYPE.CONTENT;
    if (!isBlockNode) {
      if (tag.spacing) config.spacing = tag.spacing;
      config.children = leaves;
      return new Paragraph(config);
    } else {
      if (leaves.length === 1 && leaves[0] instanceof Table) {
        // 單個`Zone`不需要包裹 通常是獨立的塊元素
        return leaves[0] as Table;
      } else {
        // 需要包裹組合嵌套`BlockTable`
        return makeZoneBlock({ children: leaves });
      }
    }
  },
};

接下來我們再來聊一下頁眉和頁腳,在word中我們常見的一個頁眉表達是在右上角標識當前頁的標題,這是個很有意思的功能,在word中是通過域來實現的,藉助於OOXML的表達和docx的封裝,我們同樣也可以實現這個功能,而且對於類似域表達的實現同樣都是可以實現的,引用標題常用的域表達是STYLEREF,我們直接拼裝字符串即可,常見的一個頁腳表達是在右下角或者居中顯示頁碼的功能,這部分就不需要域引用的表達了,我們可以非常簡單地實現頁碼的展示,主要關注的部分還是位置的控制。

const HeaderSection = new Header({
  children: [
    new Paragraph({
      style: DEFAULT_FORMAT_TYPE.HF,
      tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }],
      // ... 格式控制
      children: [
        new TextRun("頁眉"),
        new TextRun({
          children: [
            new Tab(),
            new SimpleField(`STYLEREF "${DEFAULT_FORMAT_TYPE.H1}" \\* MERGEFORMAT`),
          ],
        }),
      ],
    }),
  ],
});

const FooterSection = new Footer({
  children: [
    new Paragraph({
      style: DEFAULT_FORMAT_TYPE.HF,
      tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }],
      // ... 格式控制
      children: [
        new TextRun("頁腳"),
        new TextRun({
          children: [new Tab(), PageNumber.CURRENT],
        }),
      ],
    }),
  ],
});

word中還有一個非常重要的功能,那就是生成目錄的能力,我們先來想一個問題,不知道大家注意到沒有我們整篇文檔沒有提到字體的引入,如果我們想知道某個字或者某個段落渲染在word中的某一頁,那麼我們是需要知道字體的大小的,這樣我們纔可以將其排版,由此得到標題所在的頁數,那麼既然我們連字體都沒引入,那麼實際上很明顯我們是沒有在生成文檔的時候就進行渲染排版的執行,而是在用戶打開文檔的時候纔會進行這個操作,所以我們引入目錄之後,會出現類似於是否更新該文檔中的這些域的提示,這就是因爲目錄是字段,根據設計其內容僅由word生成或更新,我們無法以編程方式做到這一點。

const TOC = new TableOfContents("Table Of Contents", {
  hyperlink: true,
  headingStyleRange: "1-2",
  stylesWithLevels: [
    new StyleLevel(DEFAULT_FORMAT_TYPE.H1, 1),
    new StyleLevel(DEFAULT_FORMAT_TYPE.H2, 2),
  ],
}),

https://github.com/WindrunnerMax/QuillBlocks/blob/master/examples/中有完整的word數據轉換delta-to-word.tsdelta-to-word.html,可以通過ts-node和瀏覽器打開HTML來執行測試。從數據層面轉換生成word實際上是件非常複雜的問題,並且其中還有很多細節需要處理,特別是在富文本內容的轉換上,例如多層級塊嵌套、流程圖/圖片渲染、表格合併、動態內容轉換等等,實現完備的word導出能力同樣也需要不斷適配各種邊界case,同樣非常需要單元測試來輔助我們保持功能的穩定性。

jcode

PDF

在我們的SaaS平臺上的交付能力,除了Word之外PDF也是必不可少的,實際上對於很多需要打印的文檔來說,PDF是更好的選擇,因爲PDF是一種固定格式的文檔,不會因爲不同的設備而產生排版問題,我們也可以將PDF理解爲高級的圖片,圖片不會因爲設備不同而導致排版混亂,高級則是高級在其可添加的內容更加豐富,所以在本節我們就來聊一下如何生成PDF格式的交付文檔。

生成PDF的方法同樣可以歸爲兩種,一種是基於HTML生成PDF,常見的做法是通過dom-to-image/html2canvas等庫將HTML轉換爲圖片,再將圖片轉換爲HTML,這種方式缺點比較明顯,不能對文字進行選擇複製,放大後清晰度會下降,還有一種常見的方式是使用Puppeteer,其提供了高級API來通過DevTools協議控制Chromium,可以用來生成PDF文件,同樣的如果在前端直接使用window.print或者react-to-print藉助iframe實現局部打印也是可行的;還有一種方式是自行排版生成PDF,對於PDF的操作實際上非常類似於Canvas的操作,任何東西都可以通過繪製的方式來實現,例如表格我們就可以直接通過畫矩形的方式來繪製,常用的庫有pdfkitpdf-libpdfmake等等。

同樣的在這裏我們討論的方法是從我們的delta數據直接生成PDF,當然因爲我們前邊也聊了生成MDHTMLWord格式的文件,通過這些文件作爲中間層的數據進行轉換也是完全可行的,只不過在這裏我們還是採用直接輸出的方式。同樣我們也不太能在短時間內完整熟悉整個PDF數據格式的標準,所以我們同樣還是藉助於庫來生成PDF文件,這裏我們選擇了pafmake來生成PDF,通過pdfmake我們可以通過JSON配置的方式自動排版和生成PDF,相當於是從一種JSON生成了另一種JSON,而針對於Outline/Bookmark的問題,我花了很長時間研究相關實現,最終選擇了pdf-lib來最終處理生成大綱。

與生成Word的描述語言OOXML不同,OOXML中不包含任何用於直接渲染內容的繪圖指令,實際上還是相當於靜態標記,當用戶打開docx文件時會解析標記在用戶客戶端進行渲染。而創建PDF時需要真正繪製路徑PostScript-PDL,是直接描繪文本、矢量圖形和圖像的頁面描述語言,而不是需要由客戶端渲染排版的格式,當PDF文件被打開時,所有的繪圖指令都已經在PDF文件中,內容可以直接通過這些繪圖指令渲染出來。

爲了保持保持完整的跨平臺文檔格式,PDF文件中通常還需要嵌入字體,這樣才能保證在任何設備上都能正確顯示文檔內容,所以在生成PDF文件時我們需要引入字體文件。需要注意的是,很多字體都不是免費使用的,特別是在公司中很多都是需要商業授權的,同樣也有很多開源的字體,可以考慮思源宋體與江城斜宋體,這樣就包含了normalbolditalicsbolditalics四種格式的字體了,在服務端也可以考慮直接安裝fonts-noto-cjk字體並引用。此外通常CJK的字體文件都會比較大,子集化字體嵌入是更好的選擇。

// 需要引用字體 可以考慮思源宋體 + 江城斜宋體
// https://github.com/RollDevil/SourceHanSerifSC
const FONT_PATH = "/Users/czy/Library/Fonts/";
const FONTS = {
  JetBrainsMono: {
    normal: FONT_PATH + "JetBrainsMono-Regular.ttf",
    bold: FONT_PATH + "JetBrainsMono-Bold.ttf",
    italics: FONT_PATH + "JetBrainsMono-Italic.ttf",
    bolditalics: FONT_PATH + "JetBrainsMono-BoldItalic.ttf",
  },
};

pdfmake中我們同樣可以通過預設樣式來實現類似word的樣式窗格功能,當然pdf是不能直接編輯的,所以此處的樣式窗格主要是方便我們實現不同類型的樣式。

const FORMAT_TYPE = {
  H1: "H1",
  H2: "H2",
};
const PRESET_FORMAT: StyleDictionary = {
  [FORMAT_TYPE.H1]: { fontSize: 22, bold: true, },
  [FORMAT_TYPE.H2]: { fontSize: 18, bold: true, },
};
const DEFAULT_FORMAT: Style = {
  font: "JetBrainsMono",
  fontSize: 14,
};

對於轉換調度模塊,與word的調度模塊類似,我們需要定義行與行內的類型關係以及Tag需要傳遞的內容。關於pdfmake的類型控制是非常鬆散的,我們可以輕鬆地實現符合要求的格式嵌套,當然不合法的格式嵌套還是運行時校驗的,我們可以做的是儘可能地將這部分校驗提升到類型定義時,例如ContentText實際上是不能直接以ContentImage作爲子元素的,但是在類型定義上是允許的,我們可以更加嚴格地定義類似的嵌套關係。

type LineBlock = Content;
type LeafBlock = ContentText | ContentTable | ContentImage;
type Tag = {
  format?: string;
  fontSize?: number;
  isInZone?: boolean;
  isInCodeBlock?: boolean;
};

關於插件定義的部分我們還是延續之前設計的類型,這部分大致都是相同的設計,入參依然是相鄰的塊結構以及Tag,行插件還併入了葉子節點數據,插件的定義上依舊保持key插件重載、priority插件優先級、match匹配規則、processor處理函數,輸出依舊是兩種塊類型,實際上這也從側面反映了我們之前的設計還是比較通用的。

type LeafOptions = {
  prev: Op | null;
  current: Op;
  next: Op | null;
  tag: Tag;
};
type LeafPlugin = {
  key: string; // 插件重載
  priority?: number; // 插件優先級
  match: (op: Op) => boolean; // 匹配`Op`規則
  processor: (options: LeafOptions) => Promise<LeafBlock | null>; // 處理函數
};
type LineOptions = {
  prev: Line | null;
  current: Line;
  next: Line | null;
  tag: Tag;
  leaves: LeafBlock[];
};
type LinePlugin = {
  key: string; // 插件重載
  priority?: number; // 插件優先級
  match: (line: Line) => boolean; // 匹配`Line`規則
  processor: (options: LineOptions) => Promise<LineBlock | null>; // 處理函數
};

入口的Zone調度函數,與處理word的部分比較類似,因爲不存在單個塊結構的嵌套關係,同類型所有的格式配置都可以用同一個插件來實現,所以這裏同樣是命中單個插件的形式,此外同樣是首先處理葉子節點,因爲葉子節點的內容會決定行元素的嵌套塊格式。

const parseZoneContent = async (
  zoneId: string,
  options: { defaultZoneTag?: Tag }
): Promise<Content[] | null> => {
  const { defaultZoneTag = {} } = options;
  const lines = deltaSet.get(zoneId);
  if (!lines) return null;
  const target: Content[] = [];
  for (let i = 0; i < lines.length; ++i) {
    // ... 取行數據
    // 不能影響外部傳遞的`Tag`
    const tag: Tag = { ...defaultZoneTag };
    // 處理節點內容
    const ops = currentLine.ops;
    const leaves: LeafBlock[] = [];
    for (let k = 0; k < ops.length; ++k) {
      // ... 取節點數據
      const hit = LEAF_PLUGINS.find(leafPlugin => leafPlugin.match(currentOp));
      if (hit) {
        // ... 執行插件
        result && leaves.push(result);
      }
    }
    // 處理行內容
    const hit = LINE_PLUGINS.find(linePlugin => linePlugin.match(currentLine));
    if (hit) {
      // ... 執行插件
      result && target.push(result);
    }
  }
  return target;
};

緊接着是插件的定義,這裏以文本插件爲例實現轉換邏輯,類似的基本文本樣式都封裝在ContentText這個對象中,所以我們只需要處理ContentText對象的屬性即可,當然對於其他的Content類型對象例如ContentImage等,我們還是需要單獨定義插件處理的。

const TextPlugin: LeafPlugin = {
  key: "TEXT",
  match: () => true,
  processor: async (options: LeafOptions) => {
    const { current, tag } = options;
    if (!isString(current.insert)) return null;
    const config: ContentText = {
      text: current.insert,
    };
    const attrs = current.attributes || {};
    if (attrs.bold) config.bold = true;
    if (attrs.italic) config.italics = true;
    if (attrs.underline) config.decoration = "underline";
    if (tag.fontSize) config.fontSize = tag.fontSize;
    return config;
  },
};

對於行類型的插件,我們以段落插件爲例實現轉換邏輯,對於段落插件是當匹配不到其他段落格式時需要最終併入的插件,前邊我們提到的Content對象的嵌套關係也需要在此處處理,首先對於空行需要併入一個\n,如果是空對象或者空數組的話是不會出現換行行爲的,對於單個的Zone內容就不需要包裹,例如CodeBlock塊級結構則直接提升併入到主文檔即可,對於多種多種類型的結構例如並行的表格、圖片等就需要包裹一層Table/Columns結構來實現。此外與OOXML不一樣的是,層級嵌套關係出現問題不會導致打開報錯,只是不正常顯示相關區域的內容。

const composeParagraph = (leaves: LeafBlock[]): LeafBlock => {
  if (leaves.length === 0) {
    // 空行需要兜底
    return { text: "\n" };
  } else if (leaves.length === 1 && !leaves[0].text) {
    // 單個`Zone`不需要包裹 通常是獨立的塊元素
    return leaves[0];
  } else {
    const isContainBlock = leaves.some(leaf => !leaf.text);
    if (isContainBlock) {
      // 需要包裹組合嵌套`BlockTable` // 實際還需要計算寬度避免越界
      return { layout: "noBorders", table: { headerRows: 0, body: [leaves] } };
    } else {
      return { text: leaves };
    }
  }
};
const ParagraphPlugin: LinePlugin = {
  key: "PARAGRAPH",
  match: () => true,
  processor: async (options: LineOptions) => {
    const { leaves } = options;
    return composeParagraph(leaves);
  },
};

緊接着我們來聊一聊如何生成Outline/BookmarkOutline通常就是我們說的大綱,通常會顯示在打開的PDF左側。pdfmake是不支持直接生成Outline的,所以我們需要藉助其他的庫來實現這個功能,在調研了很長時間之後我發現了pdf-lib這個庫,可以用來處理已有的pdf文件並且生成Outline。在這個例子中生成PDF之後的Outline是通過id系統來實現跳轉的,實際上還有一個思路,使用pdfjs-dist來解析並存儲PDF相應標題對應的頁面與位置信息,然後再使用pdf-libOutline寫入。此外,生成Outline在配合Puppeteer來生成PDF時非常有用,本質上是因爲Chromium在導出PDF時不支持生成Outline,那麼通過pdf-lib來添加Outline恰好是不錯的能力補充。

// 通過`pdfmake`生成`pdf`
const printer = new PdfPrinter(FONTS);
const pdfDoc = printer.createPdfKitDocument(doc);
const writableStream = new Stream.Writable();
const slice: Uint8Array[] = [];
writableStream._write = (chunk: Uint8Array, _, next) => {
  slice.push(chunk);
  next();
};
pdfDoc.pipe(writableStream);
const buffer = await new Promise<Buffer>(resolve => {
  writableStream.on("finish", () => {
    const data = Buffer.concat(slice);
    resolve(data);
  });
});
pdfDoc.end();

// 通過`pdf-lib`生成`outline`
const pdf = await PDFDocument.load(buffer);
const context = pdf.context;
const root = context.nextRef();
const header1 = context.nextRef();
const header11 = context.nextRef();
// ... 創建`ref`
const header1Map: DictMap = new Map([]);
// ... 置入數據
header1Map.set(PDFName.of("Dest"), PDFName.of("Hash1"));
context.assign(header1, PDFDict.fromMapWithContext(header1Map, context));
const header11Map: DictMap = new Map([]);
// ... 置入數據
header12Map.set(PDFName.of("Dest"), PDFName.of("Hash1.2"));
context.assign(header11, PDFDict.fromMapWithContext(header11Map, context));
// ... 構建完整的層級關係
const rootMap: DictMap = new Map([]);
// ... 構建根節點的引用
context.assign(root, PDFDict.fromMapWithContext(rootMap, context));
pdf.catalog.set(PDFName.of("Outlines"), root);
// 生成並寫文件
const pdfBytes = await pdf.save();
fs.writeFileSync(__dirname + "/doc-with-outline.pdf", pdfBytes);

https://github.com/WindrunnerMax/QuillBlocks/blob/master/examples/中有完整的PDF數據轉換delta-to-pdf.tsdelta-to-pdf.html,以及添加Outlinepdf-with-outline.ts,可以通過ts-node和瀏覽器打開HTML來執行測試,特別注意使用ts-node進行測試的時候需要注意字體的引用。從數據層面轉換生成PDF本身是件非常複雜的問題,而得益於諸多的開源項目我們可以比較輕鬆地完成這件事,但是當真正地將其應用到生產環境中時,實現完備的PDF導出能力同樣也需要不斷適配各種邊界case情況,同樣非常需要單元測試來輔助我們保持功能的穩定性。

jcode

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://docx.js.org/
https://github.com/parallax/jsPDF
https://github.com/foliojs/pdfkit
https://github.com/Hopding/pdf-lib
https://quilljs.com/playground/snow
https://github.com/puppeteer/puppeteer
https://github.com/lillallol/outline-pdf
https://github.com/bpampuch/pdfmake/tree/0.2
http://officeopenxml.com/WPcontentOverview.php
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章