2つのExcelファイルを比較するツールをShinyで作成

# はじめに
2つのExcelファイルの内容を比較して差分を知りたい，ということがあるかと思います（あまりない）．Wordには公式に差分を取る機能が備わっていますし，[difff](https://difff.jp/)のようなテキストを比較するツールもありますが，Excelにはありません．ですので世の中に転がっているツールを探したのですが，あまりコレといったものがありません．Excelなので，マクロで何とかしてもよいのですが，VBAマクロのセキュリティの取り扱いが難しくなり，汎用できなくなってしまいました．仕方がないので，手元で使えるRとShinyを使って，Excelファイルを比較できるツールを作成しました．自分で使う分にはローカルでShinyを起動すればいいのですが，他の人にも使ってもらう必要があったので，shinyapp.ioで公開しています．
[CompareExcel](https://xjorv.shinyapps.io/compareExcel/)

コードは以下にあります．
[https://github.com/sb8001at/compareExcel/tree/main](https://github.com/sb8001at/compareExcel/tree/main)

なんとか動くものを作ったものの，なかなか思い通りにいかない点が多々ある仕上がりになっております．詳細について以下に記録しておきます．

# 仕組み
仕組みを簡単に説明すると，
1. 2つのExcelファイルを文字列としてRで読み込む（readxlを使用）
2. 読み込んだExcelファイルをcsvとして保存
3. csvファイルをbashのsdiffで比較し，Rで返り値を受け取る
4. sdiffの返り値を行の欠測，行中の置換に分ける
5. 欠測した行にフラグを作成し，フラグのついた行を表中で赤・太字にする（DTを使用）
6. 行中の置換をintersect関数で拾い，置換されたセルを赤・太字にする
7. sdiffの返り値，赤字で編集した表をDTで表示する

という形で，Excelをコンマ切り文字列扱いとして，sdiffで差分を評価，DTで表示しているだけのものとなります．普段はWindowsを使用しているので，Microsoft PowerShell謹製のCompare-Objectを用いて作成していたのですが，Compare-Objectの出力はどうにも理解が難しいです．行が抜けているのか，置換が起きているのか判断がつかない場合が多く，差分がある行番号もわかりません．仕方ないので，Docker（Rocker/Rstudio）を用いて，Linuxベースで開発しています．bashのdiffもCompare-Objectと比べればわかりやすいのですが，sdiffを用いると出力が非常にわかりやすいので，sdiffを用いています．

やっていることは単純なのですが，技術的にはいろいろ詰まったところがあるので，記録として以下に詳細を残しておきます．

# Excelの読み込み
Shinyのデザインはbslibで変更しています（bslib::bs_theme関数）．bs_theme関数ではBootstrapのversionと[swatch](https://bootswatch.com/)を選択できるので，バージョン5のUnitedを選択しています．
Excelの読み込みはreadxlを用いています．一応テキストも読み込みできるよう，拡張子に対応してread.csv，read.tsvでの読み込みができるようにしています．Shiny上でのファイル読み込みは，ShinyのfileInput関数で対応しています．fileInput関数はファイルのパスを返してくれるので，パスに対応してserver.R側でreadxlを用いて読み込む形となっています．

```ui.R
fluidPage(
  theme = bs_theme(version = 5, bootswatch = "united"), # Bootstrapの設定
  nav_bar_fixed = TRUE,
  navbarPage("Excelファイル比較",
             tabPanel(
               "", sidebarLayout(
                 sidebarPanel(
                   fileInput("file1", "1つ目のファイルを選択してください"), # ここでファイルのPathを取得
                   uiOutput("input_file1Sheets"), # ココは下に記載の内容
                   actionButton("file1showButton", "ファイル1を表示"),
                 ), ...
```

ただし，このままではExcelのシートまでは選択できません．Excelのシートを選択できるようにするため，ファイルへのパスが入力されたときに，ファイルがExcelならselectInputをUIに追加する形にしています．Excelでない場合にはUIに空のテキストを挿入し，シートの選択が必要ないようにしています．

```server.R
function(input, output, server) {
  # ファイル1を入力したとき，ファイル1がExcelファイルならシートを選択するUIを追加する
  observeEvent(input$file1,
               {
                # input$file1$datapathがfileInputから得られるファイルパス．stringrで拡張子を検出
                 if (input$file1$datapath %>% str_detect(".xls")) { 
                   output$input_file1Sheets <- renderUI({
                     try(selectInput("input$file1_sheet",
                                     "シート名を選択してください",
                                     excel_sheets(input$file1$datapath)),
                         silent = T)
                   })
                 } else {
                   output$input_file1Sheets <- renderText("")
                 }
               })...
```

ただし，ご存じの通りExcelはなかなか曲者で，表示している数値だけを見ると整数値だったり，規定した桁数の小数だったりするのですが，内部的にはきちんとFloatらしく丸め誤差を持っています．Excelをreadxlで読み込むと，この丸め誤差をそのまま読み込んでしまいます．このあたり，Excelが数値以外を認めないのであればRで丸めることもできるのですが，Excelのセルには決まった型がないため，どうしても取り扱いが難しくなっています（一敗）．

# sdiffでの比較
取り込んだExcelをデータフレームのまま比較できればよいのですが，直接比較するようなツールは探した限りRにはありません．ですので，bash（WindowsならPowerShell）に投げて比較してもらいます．データフレームのままbashに投げるのは難しいので，データフレームを一時的にwrite.tableでテキストとして保存し，bashのsdiffでテキスト比較をしてもらいます．bashにはsystem関数で投げ，比較した結果はintern=Tを指定することでオブジェクトとしてRで取得します（system関数はWarningを返すことが多いので，suppressWarningで黙っておいてもらいます）．ちなみにWindowsではCompare-Objectを投げる形で比較するのですが，Compare-Objectの出力は読み解くのが難しく，最終的にshinyapp.ioで公開することもあって（shinyapp.ioはLinuxサーバで稼働），bashを採用する形となっています．
```R
temp3 <- system("sdiff -s temp1.txt temp2.txt", intern=T) %>% suppressWarnings
```
sdiffの返り値では，|の記号が部分的に異なる行，>や<が行が抜けていることを示します．ですので，単純にこの記号だけで，行が抜けているのか，行中の置換なのか判別できます．
```R
different_row <- temp3[str_detect(temp3, "\\|")] %>% str_remove_all("\\t") # 置換のある行
removed_row <- temp3[!str_detect(temp3, "\\|")] %>% str_remove_all("\\t") # 無い（すべて変換されている)行
```
ただし，Excelのバッドノウハウとして，「特に意味もないのに空の列を挿入する」，という困ったものがあります．A列から埋まっているとなんか見にくい…，とかいう理由で列を挿入してしまうのですが，これがファイル1だけに1列挿入されていたりすると，テキスト変換したとき，すべての行に差があることになってしまいます．そうなると，sdiff自体も，それ以降も計算に時間がかかってしまいます．ですので，テキストにしてsdiffで比較する方法は「列挿入にめちゃくちゃ弱い」という特徴を持っています（二敗）．


# DTで変更点をラベルする
sdiffの結果を行の抜けと，部分的に変更がある場合に分けた後，それぞれについて変更点をDTでラベルします．行の抜けに関しては，このsdiffの出力を信頼して，そのままDTへのラベルに使用する形となっています．一方で，部分的な変更に関しては行のどの値が変わっているのか，検出した後でラベルする必要があります．検出にはintersectを使います．

intersectで調べるのですが，手元にあるのはsdiffに出力された，行の要素のいくつかで，行の全要素はわかりません．Excelをテキスト変換して比較しているので，sdiffの出力はコンマ切りテキストです（これはstringrでいい感じにベクトルに戻します）．そもそもどの行に部分的変更があるのかもわからないので，sdiffの出力に残っている行の要素と，元のデータフレームの行の要素のintersectを調べ，sdiffの出力と同じ数だけの要素をintersectで拾えれば，その行が部分的変更が入った行である，ということになります．ですので，以下のような形で無理やり部分的変更があった行を取得しています．
```R
for(k in 1:nrow(d1)){
  if(intersect(rm_d1_value, d1[k,] %>% unlist %>% as.character) %>% length >= (length(rm_d1_value)-2)){
    rem_d1 <- c(rem_d1, k)
    break
  }
}
```

このとき，行に重複があったりすると，上の行だけ拾ってしまって，下の行はひろえないことになります（三敗）．さらに，空白列があると行の要素にNA（もしくは空の要素）が発生してしまうので，intersectで拾うことができません（このあたりに対応するため，長さを-2しているという，謎処理が含まれています．四敗）．なかなか困ったことは多いのですが，とりあえずテストとしてはこれで動いている，というプログラム的にダメな感じになっています．

とにかく，このintersectを用いる方法で行を特定できたら，後はデータフレーム1と2の対象の行を比較するだけです．比較はsetdiffで行います．
```R
d1_diff_value <- setdiff(d1diff, d2diff) %>% paste(collapse=",") # 複数要素異なる場合には，コンマ切りで記録
```

違う要素，抜けた行がわかったら，後は対象のセルをDTで太字・赤字にするだけです．ただし，行をまるごと編集するときと，1つのセルを変更するときの対応は異なります．行をすべて太字・赤字にする場合には，削除された行に関するフラグを1列作成し，フラグに対応してformatStyleでフォントのウェイト・色を設定する形としています．

```R
d1 <- cbind(d1, deleted_in_d2) # 削除行をフラグ（削除された行を1とする）としてデータフレームに追加
datatable(d1, # DTで表記
          escape = FALSE,
          colnames = 1:ncol(d1),
          rownames = FALSE,
          options = list(
            scrollX = TRUE,
            scrollCollapse = TRUE,
            pageLength = 50,
            paging = FALSE
          )) %>% 
          formatStyle(
            'deleted_in_d2',
            target = 'row',
            fontWeight = styleEqual(c(0, 1), c('normal', 'bold')),
            Color = styleEqual(c(0, 1), c('black', 'red'))
          ) # formatStyleで，deleted_in_d2のフラグに従い色とウェイトを変更
```

セルが異なる場合には，formatStyleではなく，セルの入力をHTMLで修飾して，フォントを変更します．

```R
for(k in 1:length(dif1_cont)){ # 差がある項目分だけ繰り返す
  if(d1[dif1_row, ] %>% str_detect(dif1_cont[k]) %>% na.omit %>% sum != 0){ 
  d1[dif1_row, d1[dif1_row, ] %>% str_detect(dif1_cont[k])] <- paste0('<b><font color="red">', dif1_cont[k], '</b></font>') # 異なる項目があれば，HTMLで修飾
}}
```

このHTMLのスタイル適用にはdatatableのオプションでescape=FALSEを指定します．escape=FALSEを指定すると，DTはデータフレームの要素について，HTMLでの修飾を適用した形でDTを表示してくれます．

# 感想
こういうものをRで作るというのはそもそもあんまり適していない（RailsとかDjangoでやればいい）のはわかっていたのですが，Shinyを使えば，「作れなくはない」なあ，と感じました．ただし，どうしても必要なモノが揃っていない（sdiffなどはbashに投げざるを得ない，Excelをうまく扱うのが難しい，テキストの取り扱いがやや不便）こともあり，何敗も失敗を重ねる形となっています．

公開に関しては，shinyapp.ioを用いれば非常に簡単にクラウドに載せることができます．無料枠では1ヶ月25時間しか動かせませんが，常にたくさんの人が同時に使う，といった状況でなく，試しにアップロードしてみるぐらいなら必要十分なのでは無いかと思います．

なんというか，作ってみたものの，それっぽい屍が出来た感じがしなくもないところではあります．Rしか使えない非エンジニアが趣味で作ったものとしては，何とか機能しているといったところです．どなたかがこの屍を踏み越えて，比較ツールを作ってくれるととてもありがたいです．