JavaScriptでPDFファイルのCross-Reference TableおよびFile Trailerを作成する

Author:

クロスリファレンステーブルの役割

PDFファイルは、内部でオブジェクト(ページ、テキスト、画像、フォントや色空間など)として構造化された状態でデータを保持しています。
クロスリファレンステーブル(xrefテーブル)は、PDFファイル内のオブジェクトの位置情報を管理する役割を持っています。
これにより、PDFビューアーは素早く必要なデータを読み取ることができ、効率的に表示・処理できます。


ファイルトレーラーの役割

PDFビューアーは、PDFファイルを開く際にファイルトレーラーを見て、どこにクロスリファレンステーブルがあるかを確認します。
またPDFファイルの構造はツリーのようになっており、最上位に「ルートオブジェクト」がありますが、ファイルトレーラーを見てどのオブジェクトが「ルートオブジェクト」であるかを確認し、ルートオブジェクトを起点にページのレイアウトを決定します。
タイトル、作成者、作成日時などのメタデータをファイルトレーラーに保存することも可能です。


テキスト形式で作成したPDFファイルのCross-Reference TableおよびFile Trailerを作成するJavaScript

オブジェクトの位置情報を一つ一つ調べて入力するのは非常に手間がかかるので、JavaScriptを使用してCross-Reference Tableを作成します。ついでにFile Trailerも作成します。
オブジェクトストリームは、バイナリーによるクロスリファレンスストリームを必要としますので、テキスト形式でPDFファイルを作成するという方針と異なりますので、対応していません。
JavaScriptを作成する際の方針は以下の通りとなります。
① PDFの文字コードはUTF-8。
② 改行コードはCR+LF。
③ Rootの間接オブジェクト番号は 1。
④ オブジェクトストリームおよびクロスリファレンスストリームには対応しない。
⑤ File Trailerのstartxrefを出力するため、適当なダミーのCross-Reference TableをPDFファイル内に記述しておく。

完成例

入力: テキスト形式で作成したPDFファイル


出力: テキスト (xrefおよびtrailer)

使い方

① Cross-Reference TableおよびFile Trailerを作成したいPDFファイル(.pdf)を開きます。
② PDFファイルを開くイベントをきっかけにJavaScriptが動き、テキストエリアにCross-Reference TableおよびFile Trailerが表示されます🤗。
③ PDFファイルのソーステキストに作成したCross-Reference TableおよびFile Trailerをコピー&ペーストで挿入します。

ソース

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>テキスト形式で作成したPDFファイルのCross-Reference TableおよびFile Trailerを作成する</title>
  </head>
  <body>
    入力: テキスト形式で作成したPDFファイル <input type="file" id="input" accept="application/pdf" /><br />
    <br />
    出力: テキスト (xrefおよびtrailer)<br />
    <br />
    <div id="output"></div>
    <script>
      'use strict';

      // @ts-check

      /**
       * 標準組み込みオブジェクト
       * @typeof {object} ArrayBuffer
       * @typeof {object} DataView
       * @typeof {object} RegExpExecArray
       * Web API
       * @typeof {object} FileList
       * @typeof {object} HTMLElement
       * @typeof {object} HTMLInputElement
       * @typeof {object} HTMLTextAreaElement
       */

      //------------------------------------------------------------
      // ライブラリー
      //------------------------------------------------------------

      /**
       * バイナリーデータ(ArrayBufferオブジェクト)を16進数テキストに変換
       * @param {ArrayBuffer} arrayBuffer 16進数テキストに変換するArrayBufferオブジェクトによるバイナリーデータ
       * @returns {string} 変換された16進数テキスト
       */
      function arrayBufferToHexText(arrayBuffer) {
        // 出力用テキスト
        let hexText = '';
        /** @type {DataView} */
        const dataView = new DataView(arrayBuffer);
        for (let i = 0; i < arrayBuffer.byteLength; i ++) {
          hexText += dataView.getUint8(i).toString(16).padStart(2, '0');
        }
        return hexText;
      }

      /**
       * 16進数テキストをバイナリーデータ(ArrayBufferオブジェクト)に変換
       * @param {string} hexText ArrayBufferオブジェクトによるバイナリーデータに変換する16進数テキスト
       * @returns {ArrayBuffer} 変換されたArrayBufferオブジェクトによるバイナリーデータ
       */
      function hexTextToArrayBuffer(hexText) {
        /** @type {ArrayBuffer} */
        const arrayBuffer = new ArrayBuffer(hexText.length / 2);
        /** @type {DataView} */
        const dataView = new DataView(arrayBuffer);
        for (let i = 0; i < arrayBuffer.byteLength; i ++) {
          dataView.setUint8(i, parseInt(hexText.slice(i * 2, (i + 1) * 2), 16));
        }
        return arrayBuffer;
      }

      //------------------------------------------------------------
      // イベント  type="file"のインプット要素でファイルを選択時
      //------------------------------------------------------------
      // ts-check エラー対策 変数を用意し、nullを排除し、HTMLElementではなくHTMLInputElementとする
      const htmlInputElement = /** @type {!HTMLInputElement} */ (document.getElementById('input'));
      /** @this HTMLInputElement */
      htmlInputElement.addEventListener('change', async function() {

        //------------------------------------------------------------
        // ローカル変数の設定 変更不要
        //------------------------------------------------------------
        // ts-check エラー対策 変数を用意し、nullを排除し、FileListとする
        const fileList = /** @type {!FileList} */ (this.files);
        // Fileオブジェクトは、Blobオブジェクトからメソッドを継承
        // Blob.prototype.arrayBuffer()
        // Fileをストリームに変換し、最後まで読み込む
        /** @type {ArrayBuffer} */
        const arrayBuffer = await fileList[0].arrayBuffer();
        // バイナリーデータ(ArrayBufferオブジェクト)をUTF-8でデコードしてテキストに変換
        const inputUtf8Text = (new TextDecoder()).decode(arrayBuffer);
        // バイナリーデータ(ArrayBufferオブジェクト)を16進数テキストに変換
        const inputHexText = arrayBufferToHexText(arrayBuffer);
        // PDFファイル内のオブジェクトのバイト位置と世代番号を管理。
        // オブジェクト番号をインデックスとして使用
        let pdfObjectArray = [];
        pdfObjectArray[0] = [{ bytePosition: 0, generationNumber: 0 }]
        let maxObjectNumber = 0;
        // 出力用テキスト
        let outputText = 'xref\u000d\u000a';
        let numberOfEntriesFlag = false;
        let numberOfEntries = 0;
        // Cross-Reference Tableのバイト位置
        let xrefBytePosition = 0;

        //------------------------------------------------------------
        // Cross-Reference Table
        //------------------------------------------------------------
        const regExpExecArrays = [...inputUtf8Text.matchAll(new RegExp('(?:\\u000d\\u000a|\\u000d|\\000a)\\s*(([0-9]+)\\s*([0-9]+)\\s*obj)', 'g'))];
        for (const regExpExecArray1 of regExpExecArrays) {
          let objectNumber = Number(regExpExecArray1[2]);
          let generationNumber = Number(regExpExecArray1[3]);
          // ts-checkエラー対策 nullを排除し、RegExecArrayとする
          const regExpExecArray2 = /** @type {!RegExpExecArray} */ (new RegExp('((?:0d0a|0d|0a){1}(?:20)*)' + arrayBufferToHexText((new TextEncoder()).encode(regExpExecArray1[1]).buffer), 'g').exec(inputHexText));
          let bytePosition = (regExpExecArray2.index + regExpExecArray2[1].length) / 2;
          if (!pdfObjectArray[objectNumber]) {
            pdfObjectArray[objectNumber] = [];
          }
          pdfObjectArray[objectNumber].push({ bytePosition, generationNumber });
          maxObjectNumber = objectNumber > maxObjectNumber ? objectNumber : maxObjectNumber;
        }

        for (let i = 0; i <= maxObjectNumber; i ++) {
          if (pdfObjectArray[i]) {
            // Entryの数を決定する
            if (!numberOfEntriesFlag) {
              for (let j = i; j <= maxObjectNumber; j ++) {
                if (pdfObjectArray[j + 1]) {
                  numberOfEntries ++;
                } else {
                  numberOfEntriesFlag = true;
                  outputText += `${ i } ${ numberOfEntries + 1 }\u000d\u000a`;
                  break;
                }
              }
            }

            if (i === 0) {
              outputText += '0000000000 65535 f\u000d\u000a';
            } else {
              outputText += `${ pdfObjectArray[i][pdfObjectArray[i].length - 1].bytePosition.toString().padStart(10, '0') } ${ pdfObjectArray[i][pdfObjectArray[i].length - 1].generationNumber.toString().padStart(5, '0') } n\u000d\u000a`;
            }
          } else {
            numberOfEntriesFlag = false;
            numberOfEntries = 0;
          }
        }

        //------------------------------------------------------------
        // File Trailer
        //------------------------------------------------------------
        if (new RegExp('(?:0d0a|0d|0a){1}(?:20)*78726566(?:20)*(?:0d0a|0d|0a){1}', 'g').test(inputHexText)){
        // ts-checkエラー対策 nullを排除し、RegExecArrayとする
          const regExpExecArray = /** @type {RegExpExecArray} */ (new RegExp('((?:0d0a|0d|0a){1}(?:20)*)78726566(?:20)*(?:0d0a|0d|0a){1}', 'g').exec(inputHexText));
          xrefBytePosition = (regExpExecArray.index + regExpExecArray[1].length) / 2;
        }

        if (xrefBytePosition !== 0) {
          outputText += `\u000d\u000atrailer\u000a<< /Root 1 ${ pdfObjectArray[1][pdfObjectArray[1].length - 1].generationNumber } R /Size ${ maxObjectNumber + 1 } >>\u000d\u000astartxref\u000d\u000a${ xrefBytePosition }\u000d\u000a%%EOF`;
        } else {
          outputText += `\u000d\u000atrailer\u000a<< /Root 1 ${ pdfObjectArray[1][pdfObjectArray[1].length - 1].generationNumber } R /Size ${ maxObjectNumber + 1 } >>\u000d\u000astartxref\u000d\u000a${ arrayBuffer.byteLength }\u000d\u000a%%EOF`;
        }

        //------------------------------------------------------------
        // コンテンツ区分要素内に出力
        //------------------------------------------------------------
        // ts-check エラー対策 変数を用意し、nullを排除し、HTMLElementとする
        const htmlElement = /** @type {!HTMLElement} */ (document.getElementById('output'));
        /** @type {HTMLTextAreaElement} */
        const textareaElement = document.createElement('textarea');
        htmlElement.appendChild(textareaElement);
        textareaElement.setAttribute('cols', '80');
        textareaElement.setAttribute('rows', '20');
        textareaElement.appendChild(document.createTextNode(outputText));
      });

    </script>
  </body>
</html>

関連情報


コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です