Corredor

ウェブ、プログラミングの勉強メモ。

ブラウザ上で3ファイル以上のテキストファイルの差分を確認できる Angular アプリ「Multiple Diff」を作った

以前、「3ファイル以上を横並びにして差分を確認することはできないか」という話をした。

neos21.hatenablog.com

「Rekisa」という Windows 用フリーソフトが一番自分の理想に近かったのだが、コレを自分でも作って改良できないかやってみた。

結果、色々と挫折しつつも、それなりに差分が確認できる Angular アプリとして落ち着けることができた。以下の Angular Utilities より、Multiple Diff のページを参照してもらいたい。

用途としては、自分が作っている npm パッケージ群の package.json を横並びにして、同じバージョンのライブラリを使っているかとか、プロパティの書きぶりが揃っているかとかを確認できたりすると思う。

以下、このアプリを作るまでの詳細を語る。

差分の求め方

差分チェックのやり方は単純。行単位の完全一致でしか見ていない。

ファイル A・B・C の3ファイルがあったとして、差分比較は「A・B」「B・C」「C・A」の3回に分けて行っている。

「A と B」の比較時は、A のテキストを1行ずつループし、その行に合致する行が B に登場したら印を付けている。A の行データには isDiffNext、B の行データには isDiffPrev といったフラグを持たせ、「A から見て B に一致する行があるか」「B から見て A に一致する行があるか」をそれぞれ管理している。

同様に「B と C」も比較するワケだが、この時、B の行データには「B から見て A に一致する行があるか」のデータが既に入っている。このデータは isDiffPrev というフラグで管理しているので、「B から見て C に一致する行があるか」という情報は isDiffNext フラグで管理する。

こうすることで、ファイル B のある行が、

  • 左に置かれる = 前のファイルである、「ファイル A」の行と一致するか否か
  • 右に置かれる = 次のファイルである、「ファイル C」の行と一致するか否か

という2つの情報が保たれる。

ついでに、最終ファイルである C と、最初のファイルである A も比較して置いてある。「マリオブラザーズ」的な、「画面右端と左端が繋がっている感」がある (伝わるかなコレ…)。

1行内で文字単位で差分がある時に、「似た行」としての判定はキツそうだったので止めた。

差分の見せ方

一番困ったのがココ。

各テキストファイルの情報は、内部的には行ごとのデータに分割して、前後のファイルとの差分の有無などを管理している。よってこの配列データを順に表示すれば良いワケだが、全ファイルを横並びにして「一致行」と「差分行」を揃えて表示する仕組みが作れなかった。

2ファイル間の Diff の場合、お互いに「次に一致する行まで」で表示する行を下にズラして、適宜調整用の空行などを入れてやれば、「一致している行」と「差分がある行」を揃えて表示できる。

比較対象が3ファイルの時までは、ギリギリなんとかなりそうだった。同一の行データがあるか判定する際、何行離れているかという情報は取得していたので、中央に置かれるファイル B を基準に、

  • 左にあるファイル A との差分行数
  • 右にあるファイル C との差分行数

をチェックしながら、左右のファイル A・C もしくはファイル B 自身の行をズラしていけば、できなくはなさそうだった。

しかし、コレが4ファイル以上も可変で取得できるとなると、単純に左右のファイルをチェックするだけではダメだった。

ファイル A ファイル B ファイル C ファイル D
1 {{{{ { { {{{{
2   "name": "project-a",   "name": "project-b",   "name": "project-c",   "name": "project-d",
3   "version": "0.0.1",   "version": "0.0.1",   "version": "0.0.2",   "dependencies": {
4   "dependencies": {   "private": true,   "private": true,     "@neos21/ccc": "0.0.1",
5     "@neos21/neos21": "0.0.0"   "dependencies": {   "dependencies": {     "@neos21/req-cmd": "0.0.1",
6   }     "@neos21/ccc": "0.0.1",     "@neos21/neos21": "0.0.0"     "@neos21/neos21": "0.0.0"
7 }     "@neos21/neos21": "0.0.0"   }   }
8   } } }
9 }

例えばこんなデータがあった時、人力で「差分行」と「一致行」を比べて、表示する行位置を揃えるとすると、以下のようになると思う。というか、以下のようにしたかった。

ファイル A ファイル B ファイル C ファイル D
1 {{{{ ■(調整用空行) ■(調整用空行) {{{{
2 { {
3   "name": "project-a",(差)   "name": "project-b",(差)   "name": "project-c",(差)   "name": "project-d",(差)
4   "version": "0.0.1",(同)   "version": "0.0.1",(同)   "version": "0.0.2",(差)
5   "private": true,   "private": true,
6   "dependencies": {   "dependencies": {   "dependencies": {   "dependencies": {
7     "@neos21/ccc": "0.0.1",     "@neos21/ccc": "0.0.1",
8     "@neos21/req-cmd": "0.0.1",
9     "@neos21/neos21": "0.0.0"     "@neos21/neos21": "0.0.0"     "@neos21/neos21": "0.0.0"     "@neos21/neos21": "0.0.0"
10   }   }   }   }
11 } } } }

同じ行同士が横に並ぶよう、調整用の空行を適当な数だけ入れるのが難しい。隣のファイルの一致行とのオフセット情報だけでは、適切な空行が開けきれない。例えば「ファイル D」の4・5行目に調整用の空行があるが、「ファイル B」の "version""private" の間にさらに行があったりすると、「ファイル D」からはその行数が把握しきれない。

3行目の "name" 部分のように、差分行同士でも、行数が合わせられるならズラして表示したくなかったりもする。

最初に触れた「Rekisa」というフリーソフトは、この一致行と差分行を空行で揃えるのではなく、ファイルとファイルの間に斜めの対応線を入れることで表現していた。

ファイルごとのループの中で、行ごとのループを作って処理していくような作りだとして…。

// 擬似コード。

for(file of files) {
  // ループの最初は、file は「ファイル A」を掴んでいるものとする
  for(line of file.lines) {
    // ループの最初は、line は「ファイル A」の1行目を掴んでいるものとする。
  }
}

この line を基準に、ファイル B 〜 C の同じ行を比較したりしたとして、何を継続条件・終了条件にして、「ファイル B はココに空行を入れよう」とか「ファイル C がこうだから自分 = ファイル A の現在行に空行を追加しよう」とか決めたらいいんだろうか。バカだから整理しきれなかった。

想定しているファイルも、基本は構造がよく似たファイルばかりが4・5ファイル並ぶようなイメージでいたが、ぜんぜん違うファイルを置かれた時にぐっちゃぐちゃに破綻するのではないか、と思って、行シフトは止めることにした。


あと、行をズラすのではなく、「一致行を1行目とした、行のグループを作る」ということも考えた。以下のようなデータ構造だ。

texts = [
  {
    name: 'file-A',
    data: [
      [
        { line: 1, text: '1行目'    , isSameNext: true , isSamePrev: false },
        { line: 2, text: '2行目 AAA', isSameNext: false, isSamePrev: false }
      ],
      [
        { line: 3, text: '3行目'    , isSameNext: true , isSamePrev: true  },
        { line: 4, text: '4行目 AAA', isSameNext: false, isSamePrev: false }
      ]
    ]
  },
  {
    name: 'file-B',
    data: [
      [
        { line: 1, text: '1行目'    , isSameNext: false, isSamePrev: true  },
        { line: 2, text: '2行目 BBB', isSameNext: false, isSamePrev: false }
      ],
      [
        { line: 3, text: '3行目'    , isSameNext: true , isSamePrev: true  },
        { line: 4, text: '4行目 BBB', isSameNext: false, isSamePrev: false }
      ]
    ]
  },
  {
    name: 'file-C',
    data: [
      [
        { line: 1, text: '1行目 CCC', isSameNext: false, isSamePrev: false },
        { line: 2, text: '2行目 CCC', isSameNext: false, isSamePrev: false }
      ],
      [
        { line: 3, text: '3行目'    , isSameNext: true , isSamePrev: true  },
        { line: 4, text: '4行目 CCC', isSameNext: false, isSamePrev: false }
      ]
    ]
  }
];

うーん伝わるだろうか…。こんなデータ構造にできれば、各ファイルの data プロパティの配列ごとに上揃えで表示すればキレイに並べられるんじゃないか?と思ったのだ。

行グループ file-A file-B file-C
1 texts[0].data[0] texts[1].data[0] texts[2].data[0]
2 texts[0].data[1] texts[1].data[1] texts[2].data[1]

こういうワケだ。

だがコレも、結局複数ファイルをチェックしていって行グループ自体をズラして配置する必要が出てくると分かり、断念。「数を合わせる」ってのが無理だった。

結局、差分行はこう見せることにした

そんなこんなで、行シフトは無理だなーと判断し、ある1行に対して左右のファイルとの差分を2色で表現するようにした。つまり、

  • 右隣のファイルと差分がある行は、その行の右半分を赤色に
  • 左隣のファイルと差分がある行は、その行の左半分を緑色に

することにした。

テキストの入れ方は2種類で、テキストエリアに直接書くか、テキストファイルをアップロードするか、だ。アップロードといってもサーバに保存しているワケではなく、中身を読み込んでテキストエリアに入れたら終わり。テキストエリアに入ったテキストはその後編集もできるので、その場で差分をなくすよう書き換えられる。

それ以外に困ったこと

地味に詰まったのは、table 要素内で高さを 100% にすること。td 要素に height: 100% とするだけではダメなのだ。height のパーセンテージは、height: auto 以外の高さが明示的に指定されている親要素に遡って算出しようとするため、table 要素に table-layout: fixedheight: 100% を指定することで対応した。

また、列を動的に追加できるテーブルにしたので、幅の固定もちょっと面倒だった。こちらも table 要素に table-layout: fixed を書いておいて、width: 100% とすると上手く行った。


あと、行の左半分と右半分に差分を表現する背景色を付けているのだが、position: absolute で配置した関係上、テキストの表示領域を横スクロールした時に位置が固定できなかったので、scroll イベントを見て scrollLeft の値を style 属性でブチ込むようにした。ウィンドウのリサイズ時などの挙動にちょっとバグが残るのでご利用は計画的に。

<!-- 1ファイルの Diff 結果を表示する領域。この .diff-result-wrapper が横スクロールできる。onScrollView() 関数で、scrollLeft の値を変数 text に保持させる -->
<div *ngIf="text.diffResult" class="diff-result-wrapper" (scroll)="onScrollView($event, i)">
  <!-- 1行ごとにループする。ng-container はレンダリングされない要素なので、処理のまとまりを表現したい時とかに適宜使う -->
  <ng-container *ngFor="let line of text.diffResult">
    <!-- 1行のデータを .diff-line で囲む -->
    <div class="diff-line">
      <!--
        ::before、::after 疑似要素でもできるとは思うが、ちょっと挙動に違和感があったので空要素を作った。
        - .is-diff-next : 行の右半分に赤背景を付ける
        - .is-diff-prev : 行の左半分に緑背景を付ける
        両者に [ngStyle] で left もしくは right の値を入れている。
      -->
      <div class="is-diff-next" *ngIf="line.isDiffNext" [ngStyle]="{ 'right': -text.scrollLeft + 'px' }"><span>&nbsp;</span></div>
      <div class="is-diff-prev" *ngIf="line.isDiffPrev" [ngStyle]="{ 'left' :  text.scrollLeft + 'px' }"><span>&nbsp;</span></div>
      <!-- コレが1行の表示部分 -->
      <div class="diff-line-text">{{ line.text }}</div>
    </div>
  </ng-container>
</div>
// Diff 結果を表示するセル
td.cell-diff-result {
  height: 100%;  // 前述のセルの高さの件
  
  // DIff 結果を表示する欄
  .diff-result-wrapper {
    min-height: 100%;  // セルの高さいっぱいに表示領域を広げる (横スクロールバーの位置を揃えたいので)
    padding: .5em 0;
    overflow-x: scroll;  // 横スクロールバーを表示する
    
    // 差分行のみ色付けする
    .diff-line {
      position: relative;  // 子要素たちの基準とする
      padding: 0 .5em;
      white-space: pre;  // スペースを表示し、折返しされないようにする
      
      // .is-diff-next・.is-diff-prev より上に配置するため z-index を指定
      .diff-line-text {
        position: relative;
        z-index: 9999;
      }
      
      // 右隣と差分がある行 : 右半分を赤くする。width は若干 .is-diff-prev と被るように。.diff-line-text の下に置くため z-index を指定
      .is-diff-next {
        position: absolute;
        top: 0;
        right: 0;
        z-index: 1;
        width: 55%;
        background: linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 200, 200, 1) 30%);
        
        // 行の高さを確保するために &nbsp; を配置している span は非表示にする
        span { visibility: hidden; }
      }
      
      // 左隣と差分がある行 : 左半分を緑にする。width は若干 .is-diff-next と被るように。.diff-line-text の下に置くため z-index を指定
      .is-diff-prev {
        position: absolute;
        top: 0;
        left: 0;
        z-index: 1;
        width: 55%;
        background: linear-gradient(to right, rgba(200, 255, 200, 1) 70%, rgba(255, 255, 255, 0));
        
        // 行の高さを確保するために &nbsp; を配置している span は非表示にする
        span { visibility: hidden; }
      }
    }
  }
}
/** プレビューを横スクロールした時にカラーリング要素の配置を調整するため、横スクロール位置を控える */
public onScrollView(event: Event, index: number): void {
  if(!event || !event.target || event.target['scrollLeft'] === undefined) {
    return;
  }
  
  this.texts[index].scrollLeft = event.target['scrollLeft'];
}

コレだけ。


それから、input[type="file"] 要素の内容をリセットする方法。同名のファイルを再度アップした時も Change イベントを走らせたかったので、ファイルをアップした時に input[type="file"] 要素をリセットしようかと思ったのだが、input[type="file"]value 属性値を書き換えようとすると、セキュリティ上の理由でエラーになるようだった。

そこで、DOM 要素ごと非表示にする *ngIf ディレクティブを利用して、「ファイルをアップしたら input[type="file"] 要素を一度 DOM から削除し、再度表示させる」という動きにしてみた。DOM 要素を一度削って再度突っ込めば、セキュリティ上の理由で value 属性値が保存されない input[type="file"] 要素をリセットできるというワケだ。

<input type="file" (change)="loadFile($event, i)" *ngIf="!text.file">
/** ファイルを読み込む */
public loadFile(event: Event, index: number): void {
  // ファイルを取得 (入力チェックなどの処理は省略…)
  const file = event.target['files'][0];
  
  // ファイルリーダを用意する
  const reader = new FileReader();
  // ファイルを読み込んだらデータをセットする
  reader.onload = () => {
    this.texts[index].name = file.name;
    this.texts[index].raw = reader.result;
    this.execDiff();  // 読み込んだデータで Diff を再実行
    
    // ファイルアップロード欄をリセットする
    this.texts[index].file = true;  // コレで *ngIf により要素が DOM ツリーから消されて非表示になる
    // すぐに false に変えても上手く反映されないので、setTimeout でタイミングをズラして再表示させる
    setTimeout(() => {
      this.texts[index].file = false;
    }, 1);
  };
  
  // ファイル読み込みを実行する
  reader.readAsText(file);
}

なかなか乱暴…。

以上

巷で「3ファイルまで」の差分比較しかしてくれないツールが多い理由が何となく分かった気がする。今回はサラッと流したが Diff のアルゴリズムも突き詰めていくと難しくなりそうだし、表示時の行シフトができないのだ。

何か上手いやり方があったら教えてください。

Angular Webアプリ開発 スタートブック

Angular Webアプリ開発 スタートブック