解鎖 PDF 文件:使用 JavaScript 和 Canvas 渲染 PDF 內容

最近研究了 WebFileSystemAccess Api,它彌補了 Web 長期以來缺少的能力:操作用戶設備中的文件;而如今通過這個 Api 我們能夠實現常見的文件操作:創建、刪除、修改、移動等。

研究 FileSystemAccess 與其他相關的知識,我才發現如今的 Web 不單單只是一個網頁了,推翻了我以往對於 Web 的認知。

具體而言,PWA 能夠讓一個網站安裝至用戶設備,文件操作系統與 Storage Api 允許操作用戶文件,Share Target 能夠讓我們將已安裝網站設爲指定文件格式的打開目標,這已經能夠代替一些簡單的文件處理程序了;對於性能,WebAssembly 也在不斷髮展中,目前缺少的只有文件訪問權限的持久化,隨着發展,相信未來一些處理文件的 Web 程序更多的會以桌面應用的形式存在,就如同 Excalidraw 一般。

也因此,萌生了研究 JS 中二進制操作的想法,這裏以解析 PDF 爲例,探討研究對於 JS 中二進制操作的方式。

PDF 文檔結構

特定類型的文件,它們的文件格式總是相同的,也就是說有通用的解析方法,PDF 文件也不例外。

一個 PDF 文檔主要有以下 4 個部分:

這 4 個部分共同組成了一個 PDF 文檔。

對於一個不可執行的文件來說,它的存在只是爲了被讀取,具體的行爲是由解析器來決定的,而解析器爲了知道當前文件是否能夠被自己處理,就需要一個特定標識,這被稱爲 magic number,它通常存在於文件的頭部,PDF 的標識被定義爲 % PDF - 版本號,其中 % PDF 是固定的,對應的字節爲 0x25 0x50 0x44 0x46

在 Header 的下一行,通常會添加一些不可讀的字節數據,這些數據是爲了兼容傳統文件傳輸程序,讓它們知道當前文件是一個二進制文件。

Body

在 PDF 文檔中,並沒有一個標識 Body 區域的特徵,它只是泛指 PDF 中的頁面、資源、流等數據,這些數據被抽象爲一個個對象,通過這些對象我們才能將 PDF 文檔解析爲可讀的內容。

一個標準的對象格式爲:

 1 0 obj % 1 爲對象編號,0 爲對象版本號
 <<  >>
 endobj

Xref(交叉引用表)

Xref 是 PDF 中存儲對象偏移的區域,通過它我們能夠快速的訪問到指定的對象,而不必處理完整的文檔,這意味着解析 PDF 文檔是非常快的,舉例來說,如果我們想要解析 PDF 中的第一個頁面,首先獲取到這個頁面對象在 Xref 中的索引,然後根據存儲的偏移位置訪問到這個對象。

Xref 的表現形式如下:

xref 關鍵字標示着交叉引用表的開始,下一行的 0 39,0 表示對象開始的索引,39 表示對象的個數,在表中第一個條目表示特殊條目,不指向任何對象。

Trailer

Trailer 位於文檔尾部,其中的數據描述瞭如何讀取當前文檔,一個 PDF 解析器首先需要處理的就是這一部分的數據。

一個簡單的 Trailer 如下:

trailer 的數據存儲在字典 << >> 裏,其中必須存在 Size 和 Root 屬性,Size 標識着當前文檔的對象個數,Root 是一個間接引用,指向了當前文檔的文檔目錄,通過文檔目錄能夠獲取到頁面信息,進而找到需要解析的頁面。

在 trailer 的後面,通常跟着 startxref 的數據,這裏存放了交叉引用表的位置偏移。

PDF 數據類型

PDF 爲了方便數據的管理,定義了一些數據類型,分別有:

PDF 中通常使用字典來描述當前對象的相關信息。

除了這些基本數據外,PDF 還存在着不可被解析爲可讀文本的流數據,通常用於存儲圖像、字體和壓縮後的繪圖指令,它的表現形式如下:

 12 0 obj
 <<  >>
 stream
 % 流數據
 endstream
 endobj

PDF Parser

瞭解了 PDF 文檔的基本格式後,對於如何解析我們就有了一個概念,思路大概如下:

這些步驟執行完後,我們就能通過獲取到的數據任意的訪問需要的對象,並根據對象內容進行 PDF 頁面的渲染。當然,在此之前我們需要先獲取到文件數據。

獲取文件數據

web 想要獲取到文件,常用的方法就是 <input type="file" />,但其實它獲取的只是一個類似指針的東西,並不存在真實的數據,在獲取到文件指針後我們還需要通過 FileReader 將文件數據讀取到內存,同時因爲讀出來的數據是一個 ArrayBuffer 實例,沒有直觀的數據形式,所以我們需要將他轉換爲 TypedArray 或 DataView,代碼如下:

 const getBufferView = (file: File) ={
   return new Promise<Uint8Array>((resolve, reject) ={
     const reader = new FileReader();

     reader.onload = () ={
       resolve(new Uint8Array(reader.result as ArrayBuffer));
     };

     reader.onerror = reject;

     reader.readAsArrayBuffer(file);
   });
 };

這裏我們將文件讀取後轉換爲了 Uint8Array 實例,之所以選擇它,是因爲 PDF 中可讀數據都存在於 ASCII 碼錶中,而 8 位二進制所能表示的最大值是 255,足以表示 ASCII 碼錶中的所有數據。

這裏我隨便選擇了一個 PDF 文檔,打印出來的數據如下:

圖片中,25 是一個 16 進制數值(兩個 16 進制所能表示的最大值是 255,也就是一字節),轉換爲 10 進製爲 37,在 ASCII 碼錶中對應的字符就是 %。

數據標識

雖然 PDF 中的一些輔助信息通常是可讀的字符串,但通過字符串的方式解析這些信息是不可取的,因爲字符串操作本身存在性能問題,而我們要處理的又是動輒數以萬計的字節數據,所以這裏我採用了直接對比字節數據的方式,爲此我封裝了一個工具函數:

 const isTypeOf =
   (binary: number[]) =(maybe: Uint8Array, offset?: number) =>
     binary.every((correct, i) =correct === maybe[i + (offset || 0)]);

這個工具函數接收一個字節標識,返回一個對比函數,對比函數通過對字節標識的按位對比來判斷數據是否相等,需要注意的是對比函數還支持一個可選的字節偏移,指示應從數據的哪個位置開始對比。這個函數的使用如下:

 const Flag = {
   /** pdf magic number */
   IS_PDF: [0x25, 0x50, 0x44, 0x46]
 };

 const isPDF = isTypeOf(Flag.IS_PDF);

 isPDF(uint8); // PDF 的 magic number 位於文件的頭部,偏移爲 0,可以不用指定偏移數

數據類型解析

PDF 中,trailer 和對象的描述信息都存在於字典中,而字典中又可能存在其他的 PDF 數據類型,因此只有能夠解析它們,我們才能獲取到其中的數據。

個人的思路是仿照 vue 對於模板字符串的解析一樣,維護一個狀態棧,不斷的讀取數據,當遇到標識的開頭時,將特定的數據推入棧中,遇到標識的結尾時,將這個數據彈出,這樣能夠保證數據的層級,同時可以判斷解析是否出錯(遇到標識結尾時與狀態棧頂進行對比)。

例如:在遇到字典開始的字節數據時([0x3c, 0x3c]),將表示字典的標識數據推入棧中,在遇到字典結束的字節數據時([0x3e, 0x3e]),將棧頂數據彈出並判斷棧頂數據是否爲字典標識。

相關代碼如下:

 /** 將 Uint8Array 解碼爲可讀文本 */
 const toText = (binary: Uint8Array) => new TextDecoder().decode(binary);

 class PDFParser {
   /** 被處理的文件數據 */
   bytes: Uint8Array;
   /** 字節偏移 */
   offset = 0;
   /** 緩存字節偏移,當解析不成功時回退偏移 */
   beforeOffset = 0;
   /** 數組解析深度 */
   depth = 0;
   /** 狀態棧 */
   stateStack: PDF.StateStack = [];

   constructor(bytes: Uint8Array) {
     /** 文件數據 */
     this.bytes = bytes;
   }

   /** 字節偏移前進控制 */
   forward(step?: number) {
     return isNumber(step) ? (this.offset += step) : ++this.offset;
   }

   /** 字節偏移後退控制 */
   back(step?: number) {
     return isNumber(step) ? (this.offset -= step) : --this.offset;
   }

   /** 字節偏移設置 */
   set(before: number) {
     return (this.offset = before);
   }

   /** 緩存當前字節偏移 */
   cache() {
     this.beforeOffset = this.offset;
   }

   /** 回退字節偏移 */
   reset() {
     this.offset = this.beforeOffset;
   }

   /** 解析值 */
   parseValue(stream: Uint8Array, decision?: ReturnType<typeof isTypeOf>) {
     /** 數據開始 */
     let startOffset = this.offset;

     const isBreak = (bytes: Uint8Array, offset?: number) =>
       decision
         ? !decision(bytes, offset)
         : !isBreakPoint(bytes, offset) &&
           !isEnd(bytes, offset) &&
           !isStart(bytes, offset);

     for (; ; this.forward()) {
       /** 不爲斷點則視爲數據開始 */
       if (isBreakPoint(stream, this.offset)) continue;

       /** 開始偏移 */
       startOffset = this.offset;

       for (; ; this.forward()) {
         /** 爲斷點處或爲特徵數據結尾則視爲數據結束 */
         if (isBreak(stream, this.offset)) continue;

         return stream.slice(startOffset, this.offset);
       }
     }
   }

   /** 解析數字 */
   parseNumber(stream: Uint8Array) {
     /** 緩存偏移 */
     this.cache();

     /** 解析值請判斷是否爲數字 */
     const num = window.parseFloat(toText(this.parseValue(stream)));

     if (!isNumber(num)) {
       /** 不爲數字則回退偏移 */
       this.reset();

       return false;
     }

     /** 否則返回數字 */
     return num;
   }

   /** 解析引用:1 0 R */
   parseQuote(stream: Uint8Array) {
     /** 解析數字 */
     const serial = this.parseNumber(stream);

     /** 不爲數字說明不是間接引用 */
     if (!isNumber(serial)) return false;

     /** 緩存偏移 */
     this.cache();

     /** 是否爲數字,且後跟 R 標識 */
     const version = window.parseFloat(toText(this.parseValue(stream)));
     const isQuoteFlag = isQuote(this.parseValue(stream));

     /** 不爲數字或不存在 R 標識則回退偏移,並返回第一個數字 */
     if (!isNumber(version) || !isQuoteFlag) {
       this.reset();

       return serial;
     }

     /** 返回間接引用 */
     return { type: 'quote', serial, version } as const;
   }

   /** 解析數組 */
   parseMultivalued(stream: Uint8Array) {
     const values: unknown[] = [];

     let value: unknown = undefined;

     /** 解析引用或數字 */
     const addQuote = () ={
       value = this.parseQuote(stream);

       value !== false && values.push(value);
     };

     /** 默認執行一次 */
     addQuote();

     /** 不爲數組結束符號 ] 時執行循環體 */
     while (!isSquareBracketEnd(stream, this.offset)) {
       switch (true) {
         /** 解析字典 */
         case isDictionaryStart(stream, this.offset):
           values.push(this.parseDictionary(stream));

           break;

         /** 解析數組 */
         case isSquareBracketStart(stream, this.offset):
           this.forward(Feature.SQUARE_BRACKET_START.length);

           /** 解析深度加 一 */
           this.depth++;

           values.push(this.parseMultivalued(stream));

           break;

         /** 解析名稱 */
         case isInclined(stream, this.offset):
           this.forward(Feature.INCLINED.length);

           values.push({ type: 'name', value: toText(this.parseValue(stream)) });

           break;

         /** 解析字符串 */
         case isArrowStart(stream, this.offset):
             this.forward();

           values.push(toText(this.parseValue(stream, isArrowEnd)));

           break;

         /** 解析字符串 */
         case isParenthesesStart(stream, this.offset):
           this.forward();

           values.push(toText(this.parseValue(stream, isParenthesesEnd)));

           break;

         default:
           this.forward();

           addQuote();

           break;
       }
     }

     /** 遞歸解析數組, 如果解析深度不爲 0, 則上層數組還需繼續解析數組元素 */
     if (this.depth !== 0) {
       /** 字節偏移前進一位,避免上層數組解析到內部數組的 ] 符號而中止循環 */
       this.forward(Feature.SQUARE_BRACKET_END.length);

       this.depth--;
     }

     return values;
   }

   /** 解析字典 */
   parseDictionary<T>(stream: Uint8Array): T {
     /** 字典數據 */
     const dictionary = {} as T;

     /** 是否結束當前解析 */
     let jumpOut = false;

     /** 當前鍵 */
     let key = '';

     /** 頂部棧數據 */
     const top = () => this.stateStack[this.stateStack.length - 1];

     /** 狀態棧彈出 */
     const popStack = (key: PDF.StateStackKeys) ={
       const topKey = this.stateStack.pop();

       /** 頂部棧數據與當前數據不一致時,則解析出錯 */
       if (!isEqual(topKey, key)) {
         throw Error(
           'analyze the PDF error, please contact the plug -in developer'
         );
       }
     };

     /** jumpOut 在遇到字典結尾符號 >> 時會爲 true,則停止循環解析 */
     while (!jumpOut) {
       switch (true) {
         /** 字典開頭 <<,字節爲 [0x3c, 0x3c] */
         case isDictionaryStart(stream, this.offset):
           this.forward(Feature.DICTIONARY_START.length);

           this.stateStack.push('DICTIONARY_START');

           if (key !== '') {
             // 遞歸字典解析
             dictionary[key] = this.parseDictionary(this.bytes);

             key = '';
           }

           break;

         /** 字典結尾 >>,字節數據爲 [0x3e, 0x3e] */
         /** 可能是字符串與字典結尾 >>>,因此需要判斷頂部棧是否是字典結尾 */
         case isEqual(top()'DICTIONARY_START') &&
           isDictionaryEnd(stream, this.offset):
           this.forward(Feature.DICTIONARY_END.length);

           popStack('DICTIONARY_START');

           jumpOut = true;

           break;

         /** 數組開始 [,字節數據爲 [0x5b] */
         case isSquareBracketStart(stream, this.offset):
           this.forward(Feature.SQUARE_BRACKET_START.length);

           this.stateStack.push('SQUARE_BRACKET_START');

           // 數組解析
           dictionary[key] = this.parseMultivalued(stream);

           key = '';

           break;

         /** 數組結束 ],字節數據爲 [0x5d] */
         case isSquareBracketEnd(stream, this.offset):
           this.forward(Feature.SQUARE_BRACKET_END.length);

           popStack('SQUARE_BRACKET_START');

           break;

         /** 名稱 /,字節數據爲 [0x2f] */
         case isInclined(stream, this.offset):
           this.forward(Feature.INCLINED.length);

           /*
            * 解析名稱,需要注意的時,字典中的數據是兩兩成對的,鍵爲名稱,值也可以是名稱,所以這裏通過一個變量 key 緩存鍵,
            * 當再次進入到當前代碼塊是判斷 key 是否存在數據,存在則當前名稱應作爲值。
            **/
           if (key !== '' && key in dictionary && isUndef(dictionary[key])) {
             dictionary[key] = {
               type: 'name',
               value: toText(this.parseValue(stream))
             };

             key = '';
           } else {
             key = toText(this.parseValue(stream));

             /**
              * 遇到名稱時,如果這個名稱沒有被作爲值使用,則會默認繼續一次,如果解析的數據不爲數字或引用,
              * 則會回退字節偏移並繼續循環解析。
             */
             dictionary[key] = this.parseQuote(stream) || undefined;
           }

           break;

         /** 16 進制字符串開始 <,字節數據爲 [0x3c] */
         case isArrowStart(stream, this.offset):
           this.forward(Feature.ARROW_START.length);

           this.stateStack.push('ARROW_START');

           // 字符串解析
           dictionary[key] = toText(this.parseValue(stream, isArrowEnd));

           key = '';

           break;

         /** 16 進制字符串結束 >,字節數據爲 [0x3e] */
         case isArrowEnd(stream, this.offset):
           this.forward(Feature.ARROW_END.length);

           popStack('ARROW_START');

           break;

         /** 字符串開始 (,字節數據爲 [0x28] */
         case isParenthesesStart(stream, this.offset):
           this.forward(Feature.PARENTHESES_START.length);

           this.stateStack.push('PARENTHESES_START');

           // 字符串解析
           dictionary[key] = toText(this.parseValue(stream, isParenthesesEnd));

           key = '';

           break;

         /** 字符串結束 ),字節數據爲 [0x29] */
         case isParenthesesEnd(stream, this.offset):
           this.forward(Feature.PARENTHESES_END.length);

           popStack('PARENTHESES_START');

           break;

         default:
           /** 沒有匹配時默認前進一位偏移 */
           this.forward();

           break;
       }
     }

     /** 返回字典對象 */
     return dictionary;
   }
 }

上述代碼雖然多,但基本操作都是相同的,只是不斷的前進字節偏移並對匹配的數據做處理,需要注意的只是對於字節偏移的管理。

這裏我們以 trailer 中的圖片爲例,它所解析出來的數據如下:

截圖中,Root 對象的 type 屬性是自定義的,表明這個對象是一個間接引用,serial 引用的對象編號,通常也可以看作是在交叉引用表中的索引,version 則是引用對象的版本號。

交叉引用表解析

到目前爲止,我們只知道文檔目錄的對象編號(上文中 Root 的 serial 屬性),而不知道文檔目錄所在的字節偏移,因此接下來我們需要解析交叉引用表,代碼如下:

 getPdfXref(stream: Uint8Array) {
   // startxref 記錄了交叉引用表所在的字節偏移
   /** 設置偏移爲 startxref + xref 的長度 */
   this.set(this.startxref + Flag.XREF.length);

   /** 解析對象開始編號 */
   this.parseValue(stream);

   /** 解析對象個數 */
   let size = window.parseFloat(toText(this.parseValue(stream)));

   while (size--) {
     /** 解析對象偏移 */
     this.xref.push(window.parseFloat(toText(this.parseValue(stream))));

     /** 未知 */
     this.parseValue(stream);

     /** 標識 */
     this.parseValue(stream);
   }
 }

這裏我們着重講解下 parseValue 方法的作用,在 PDF 中,數據分割通常使用空格符、換行符來實現,parseValue 就是在不斷的前進字節偏移中,找到不爲分割符號的字節偏移,將其作爲數據的開始,然後繼續前進,當遇到分割符號時,將當前的字節偏移作用數據的結束,並將兩個字節偏移中的數據切割出來。

使用上述代碼對於 Xref 解析出來的數據如下:

現在,我們已經能夠通過對象編號與交叉引用表獲取到指定對象的字節偏移了,在上文中,Root 文檔對象的字節偏移可以通過 xref [Root.serial] 來獲取。

Catalog 與 Pages

在 trailer 中,Root 指向了文檔目錄,文檔目錄的基本格式如下:

其中,Type 和 Pages 是必須的,Type 必須是 Catalog,表示這是一個文檔目錄;而 Pages 引用了 PDF 中頁面樹的根節點對象。

Pages 根節點對象的基本格式如下:

這裏我們只需要關注 Kids 屬性所對應的數據,它是一個數組,數組中的每個間接引用就是一個頁面,接下來我們可以通過頁面對象的內容使用 Canvas 來繪製 PDF 文檔了。

Canvas Draw

這裏我們以上文中的第一個頁面爲例,講解如何解析頁面內容並繪製到 canvas 中。該頁面的對象編號爲 6,它的基本信息如下:

對於一個頁面對象來說,它必須存在以下屬性:

解碼繪圖指令

上述頁面的內容被存放在對象 7 中,它的基本信息如下:

Filter 指示數據使用了什麼壓縮算法,Length 則表示壓縮後數據的字節長度,在字典信息後,跟隨着 stream 關鍵字,表示流數據的開始,endstream 則標識着流數據的結束。

對於壓縮算法,個人不是很瞭解,所以這裏使用了第三方類庫 pako 來對流數據進行解壓縮,上圖中的流數據被解壓後是可讀的繪圖指令,如下圖:

繪製文檔

接下來我們要做的事情就很明確了,我們只需要不斷的讀取繪圖指令並使用 canvas 進行繪製,就可以將一個 PDF 文檔展示出來了,但我們需要注意兩者之間的差異:canvas 和 PDF 的座標系是不同的,且 PDF 中使用的單位是 Point 而不是 Pixel。

對於 PDF 中的繪圖指令來說,它是後置位的,也就是說操作數在前,操作符在後,我們可以通過一個棧來維護其中的數據,以下圖爲例:

前面 6 個數值爲操作數,cm 則是一個表示轉換矩陣的操作符,當我們讀取時,將數值依次壓入棧中,當讀取到 cm 指令時將棧中的數據依次出棧並作爲指令的參數。

繪製圖像

PDF 的 Do 操作符允許在當前位置輸出一個外部對象,通常是一張圖片,Do 操作符的入參是一個名稱,這個名稱映射了一個間接引用,通過引用可以知道它的字節偏移,從而解析相對應的圖像數據並繪製到 canvas 中,注意,對於圖像來說,必須在 onload 事件之後繪製到畫布上,否則會無法顯示。

解析文字

PDF 中,通常不會直接存儲頁面上的文本,而是轉換爲 Unicode 編碼,並將其與另一個 Unicode 編碼值相映射,在展示文本時,找到對應的字體對象,通過 ToUnicode 引用找到映射對象,解壓其中的流數據就能找到實際的文字內容,如下圖:

解壓圖中 obj 13 的流數據後會得到類似下圖的數據:

左側的 16 進制字符串是在繪製指令中使用,右側的 16 進制字符串纔是真正的 Unicode 碼值,可以通過 String.fromCodePoint 轉換爲文字內容。

示例

個人寫了一個簡單的 PDF 解析示例,因爲對於繪圖相關知識不是很懂,解析出來的位置,偏移還有轉換等還有毛病,同時因爲只是參照一兩個 PDF 文件進行代碼編寫的,所以會有解析失敗的情況,以下是個人簡歷解析出來的樣式:

可能是矩陣轉換的原因,圖片角度是有問題的,在繪製文本時,矩陣的縮放係數是很小的,導致無法看到文本,這裏還能看到是因爲做了處理。

最後

參考:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/eIEpVVioTsbWUHiOEEYGmw