3行まとめ
- 2つのExcelファイルを行・列単位で比較するブラウザ完結ツールを作った。肝はExcelを比較用の中間表現(CSVテキスト)に正規化してから、既存のCSV差分エンジンにそのまま載せる設計
- SheetJSで
XLSX.read→sheet_to_csvすれば、あとはCSV差分ツールと同じcomputeCsvDiffを再利用できる。差分ロジックは1つで済む - 複数シートはBefore/Afterそれぞれプルダウンで選択。比較対象はセルの値のみで、色・罫線・フォントなどの書式は落ちる
Excelの新旧を比べたい——月次集計表の更新前後、複数人で編集した見積書、システムがエクスポートした台帳のバージョン差分。だが .xlsx は実体がZIPで固めたXMLの束なので、git diff にかけてもバイナリ扱いで中身は読めない。Excelの「変更履歴」機能は事前にブックへ設定しておかないと使えず、後から2ファイルを突き合わせるのには向かない。結局、目視でセルを追う羽目になる。
ぱんだツールズのExcel差分比較は、2つのExcelファイルをシート単位で読み込み、行の追加・削除・変更をセル単位でハイライトするツール。処理はすべてブラウザ内で完結し、Excelの内容はサーバーに送信されない。人事・給与・経理のような機密データも、オンラインの比較サービスにアップロードせず安全に比べられる。
この記事では、Excelの差分を「ゼロから実装しない」ための設計——SheetJSでExcelをCSVに正規化し、CSV用に作った差分エンジンをまるごと再利用する構成を、実装ベースで解説する。
設計の起点:差分エンジンを2つ持たない
先に結論を書くと、このツールはExcel専用の差分アルゴリズムを一切書いていない。すでに作ってあったCSV差分ツールの差分コア computeCsvDiff を、そのまま流用している。
import { parseCsv } from '@/lib/csv/editCsvColumns'
import { computeCsvDiff, buildCsvDiffReport } from '@/lib/csv/computeCsvDiff'
Excelの差分もCSVの差分も、本質は「行×列の表の突き合わせ」で同じ。だったら差分ロジックを2本持つ理由はない。違うのは入り口——Excelという形式を、差分コアが食える形(CSVテキスト)に変換するレイヤだけを足せばいい。
Excelファイル ──[SheetJS]──> CSVテキスト ──[computeCsvDiff]──> 差分結果
CSVファイル ────────────> CSVテキスト ──[computeCsvDiff]──> 差分結果
CSV側とExcel側で「CSVテキストに正規化する」入り口だけが違い、そこから先の差分計算・レポート生成・ハイライト表示は完全に共通。差分アルゴリズム自体(キー列でのバッグマッチング、列構成が違う表の列ユニオン、セル単位の変更検出)はCSV差分の記事で詳しく書いたので、この記事はExcel→CSVの変換レイヤに集中する。
SheetJSでExcelを読み、シートをCSVに落とす
変換レイヤの主役はSheetJS(xlsx)。.xlsx も旧形式の .xls も同じAPIで読める。
ファイルを選んだら ArrayBuffer として読み込み、XLSX.read でワークブックにする。
async function handleFileLoad(side: 'left' | 'right', file: File) {
const buffer = await file.arrayBuffer()
const workbook = XLSX.read(buffer, { type: 'array' })
const sheetNames = workbook.SheetNames
const selectedSheet = sheetNames[0] ?? ''
const worksheet = selectedSheet ? workbook.Sheets[selectedSheet] : undefined
const csvText = worksheet ? XLSX.utils.sheet_to_csv(worksheet) : ''
// ...state更新
}
ポイントは XLSX.utils.sheet_to_csv(worksheet)。ワークシートをCSVテキストに書き出すユーティリティで、これを通すと以降は「ただのCSV」として扱える。{ type: 'array' } は入力が ArrayBuffer(や Uint8Array)のバイト列であることをSheetJSに伝えるオプションで、file.arrayBuffer() の戻り値をそのまま渡せる。ブラウザでファイルを読むときはこれが基本になる。
sheet_to_csv が書き出すのはセルの表示テキスト。数式セルはファイルに保存済みの計算結果が入り、日付や通貨などはExcel上の表示形式に沿った文字列になる。逆に言えば、セルの色・罫線・フォント・結合といった書式情報はこの時点で全部そぎ落とされる。「見た目の差分」ではなく「値の差分」を取るツール、という性格はここで決まる。
複数シートは左右それぞれで選ばせる
Excelは1ファイルに複数シートを持てる。「Sheet1だけ比べたい」「集計タブ同士を比べたい」というケースがあるので、シートが2枚以上あるときだけプルダウンを出し、Before/Afterで独立に選べるようにした。
function handleSheetChange(side: 'left' | 'right', sheetName: string) {
const current = side === 'left' ? left : right
if (!current.workbook) return
const worksheet = current.workbook.Sheets[sheetName]
const csvText = worksheet ? XLSX.utils.sheet_to_csv(worksheet) : ''
// 選択シートだけCSVを差し替え
}
XLSX.read の結果(workbook)は状態として保持しておき、シート切り替えのたびに sheet_to_csv をやり直すだけ。ファイルを読み直す必要はない。左右のシート選択が独立しているので、「AファイルのSheet1」と「BファイルのSheet2」のように、名前が違うシート同士も突き合わせられる。
キー列の候補は「両シート共通のヘッダー」だけ
行の並び順が違っても正しく比較するには、会員IDや商品コードのようなキー列を指定する。ここで気をつけたのが、キー列のドロップダウンに出す候補を両方のCSVに共通して存在する列だけに絞ること。片方にしかない列をキーに選んでも突き合わせは成立しないからだ。
const leftHeaders = useMemo(
() => (left.csvText.trim() ? parseCsv(left.csvText).headers : []),
[left.csvText],
)
const rightHeaders = useMemo(
() => (right.csvText.trim() ? parseCsv(right.csvText).headers : []),
[right.csvText],
)
const commonHeaders = useMemo(
() => leftHeaders.filter((h) => rightHeaders.includes(h)),
[leftHeaders, rightHeaders],
)
ヘッダーを取り出すのに使っている parseCsv は、差分コアが内部で使うのと同じCSVパーサ。sheet_to_csv の出力をそのパーサで読み直してヘッダー行だけ取っている。Excelを一度CSVに落としたおかげで、UIのヘッダー解析までCSV用の道具で完結するのが、この正規化設計の副次的な旨味。
さらに、ファイルを差し替えて共通ヘッダーが変わったとき、いま選んでいるキー列がもう存在しないなら選択をリセットする。
useEffect(() => {
if (keyColumn !== '' && !commonHeaders.includes(keyColumn)) setKeyColumn('')
}, [commonHeaders, keyColumn])
これをやらないと「存在しないキー列」が選ばれたまま比較ボタンを押せてしまい、差分コア側が キー列「◯◯」が左側のCSVに見つかりません と例外を投げる。UI側で候補を絞りつつ、消えた選択を自動で戻すことで、その例外を未然に潰している。
あとは共通の差分コアに丸投げする
ここまで来れば、左右のCSVテキストとキー列を差分コアに渡すだけ。
function handleCompare() {
try {
const diffResult = computeCsvDiff(
left.csvText,
right.csvText,
keyColumn === '' ? null : keyColumn,
)
setResult(diffResult)
setReportBlob(
new Blob([buildCsvDiffReport(diffResult)], { type: 'text/csv;charset=utf-8;' }),
)
} catch (err) {
setDiffError(err instanceof Error ? err.message : 'Excelの比較に失敗しました')
}
}
computeCsvDiff の中身——キー列を指定したときの Map+FIFOキューによるバッグマッチング(同じキーが複数行あっても出現順に対応づける)、BeforeとAfterで列構成が違うときの列ユニオン、「列が無い(null)」と「値が空('')」の区別、セル単位の変更検出——は、CSV差分とまったく同じものが動く。Excel側で足したのは変換レイヤだけなので、CSVツールを改良すればExcelツールも同時に賢くなる。
差分結果は buildCsvDiffReport で「状態」列(変更なし/追加/削除/変更前/変更後)付きのCSVレポートに書き出せる。Excelで比較して、結果はCSVで受け取り、Excelに取り込み直して「状態」列でフィルタする、といった往復もできる。
割り切ったこと・注意点
この設計は「Excelを値の表として正規化する」ことで差分エンジンを共有できた反面、正規化で捨てたものがある。実務で使うときの前提として明示しておく。
-
書式は比較しない:
sheet_to_csvを通した時点で色・罫線・フォント・セル結合は消える。「セルの塗りが変わった」は検出できない。あくまで値の比較 -
数式は結果値で比較:
=SUM(...)のような数式そのものではなく、ファイルに保存された計算結果を比べる。ロジックの差分ではなく、出てきた数値の差分になる -
日付・数値は表示形式に依存:
sheet_to_csvはセルの表示テキストを出すため、同じ値でもBefore/Afterで表示形式(2026/01/01と2026-01-01など)が違えば「変更」に見えることがある
書式まで含めた完全な差分がほしい場面には向かないが、「値がどこで変わったか」を機密データを外に出さずに素早く洗い出す、という用途にはこの割り切りがちょうどいい。SheetJSでExcelを扱うときのハマりどころ(型変換や日付シリアル値など)はSheetJSでExcel↔CSV変換する時の落とし穴にまとめてある。
まとめ
- Excel差分は専用アルゴリズムを書かず、SheetJSでCSVテキストに正規化してから既存のCSV差分エンジンに載せる構成にした。差分ロジックは1本で、CSVツールとExcelツールが共有する
- 変換レイヤは
XLSX.read(buffer, { type: 'array' })→XLSX.utils.sheet_to_csv(worksheet)の2ステップ。.xlsxも.xlsも同じAPIで読める - 複数シートは左右独立のプルダウンで選択し、ワークブックを保持したまま
sheet_to_csvをやり直す。キー列候補は両シート共通のヘッダーに絞り、消えた選択は自動リセット - 正規化の代償として書式は比較対象外。数式は結果値、日付・数値は表示形式ベースで比べる、という割り切りがある
Excelを丸ごとサービスにアップロードせず、ブラウザの中だけで新旧を突き合わせたいときにどうぞ。
ぱんだツールズ では他にも CSV差分比較・CSV文字コード変換・銀行明細CSV変換・PDF処理など、日本語の実務ファイルに強いブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。