📝

Excelファイルが破損? 自社プロダクトのバグ調査からOSSコントリビュートまでの記録

に公開

はじめに

※この記事は、KNOWLEDGE WORK Blog Sprint第24日目の記事になります。

ナレッジワークでバックエンドエンジニアをしているtorotakeです。
各プロダクトを横断する機能開発を中心に受け持つCross Product Dev Groupというチームに所属しています。

先日、プロジェクトで見つかった問題の調査、修正過程で利用しているOSSの問題に気付き、
その修正をOSSへコントリビュートするにまで至りました。

本記事では、その経緯を共有します。

問題の事象

弊社サービスには、利用状況データなどをExcel形式(.xlsx)でダウンロードする機能があります。
この機能の実装にxlsxファイルの生成にGo言語のライブラリであるExcelize ( https://github.com/qax-os/excelize ) を利用しています。

ある日、QAエンジニアから生成されたファイルをExcelで開くと、次のような警告が出るとの報告があり調査をすることになりました。

Excelの警告メッセージ「一部の内容に問題が見つかりました。可能な限り内容を回復しますか?」

結果の修復先問題のファイル0.xml
ファイル '/path/to/問題のファイル.xlsx' にエラーが検出されました

修復されたレコード: /xl/worksheets/sheet1.xml パーツ内の文字列プロパティ

xlsxファイルはZIP形式で圧縮された複数のXMLファイルで構成されています。
サービスで出力した問題のあるファイルを展開して中身を確認すると、sheet1.xmlの該当セルには32,767文字が入っていました。
一方、そのファイルをExcelが修復した後に保存したものを同様に確認すると、該当セルの文字列は一部切り捨てられていました。
このことから、文字数の制限を超えてしまっていると想像できます。

原因

Excelのセルの制限は Microsoftの公式ドキュメント に記載がある通り、32,767文字です。

Excelizeにはセルの文字数制限を超えた場合に自動で切り詰める処理が入っています。

https://github.com/qax-os/excelize/blob/4180c2082e5b79c4d7ac7e8877c9159ae9c9fad3/cell.go#L513-L515

それなのにも関わらず、なぜ制限を超えたままの文字列が入ってしまう場合があるのかというと、Excelizeも文字数の数え方がExcelとは異なっているからです。

結論から言うと、xlsx内のXMLファイルの文字コードはUTF-8ですが、Excel上で扱われる「文字数」とはUTF-16のコードユニット数であり、UTF-16で表現した時にサロゲートペア[1]になる文字は2文字としてカウントされます。
(さらに異体字セレクタ、合字、結合文字列[2]等が組み合わさると更に増えます。)
これはExcelのLEN関数で取得できる文字数と同じです。

ExcelでLEN関数により取得できる文字数

golangでは、len(string) はバイト数を返し、文字数を知るには len([]rune(string))utf8.RuneCountInString(string) のようにする必要があるというのはよく知られていると思います。
この文字数はUnicodeコードポイント数であり、Excelで文字数としてカウントされるUTF-16コードユニット数とは異なります。
Excelizeではruneで数えている(=Unicodeコードポイント数を数えている)ため、一致しなかったのですね。

golangでUTF-16コードユニット数を知るには、unicode/utf16パッケージを使って以下のようにします。

len(utf16.Encode([]rune(s)))

// ↑ はruneとutf16スライスを生成するためメモリを多く消費します。
// 長い文字列が入りうることを考慮するなら1文字ずつ数えるのも手です。
var count int
for _, r := range s {
    count += utf16.RuneLen(r)
}
strs := []string{"A", "あ", "𩸽", "😀", "禰󠄀", "👨🏻‍🦱"}
for _, s := range strs {
	fmt.Printf("%s : %d bytes, %d unicode code points, %d UTF-16 code units\n", s, len(s), len([]rune(s)), len(utf16.Encode([]rune(s))))
}

// A : 1 bytes, 1 unicode code points, 1 UTF-16 code units
// あ : 3 bytes, 1 unicode code points, 1 UTF-16 code units
// 𩸽 : 4 bytes, 1 unicode code points, 2 UTF-16 code units
// 😀 : 4 bytes, 1 unicode code points, 2 UTF-16 code units
// 禰󠄀 : 7 bytes, 2 unicode code points, 3 UTF-16 code units
// 👨🏻‍🦱 : 15 bytes, 4 unicode code points, 7 UTF-16 code units

ExcelのLEN関数と一致していることがわかりますね。

これを利用してExcelizeを使った書き込み処理の前に自前で切り詰める処理を入れて、問題修正としてはひとまず完了です。
切り詰めるときにはコードポイントの途中で切ってしまわないように気をつけましょう。

OSSへのコントリビュート

自社のプロダクトの問題は解決しましたが、ここまで来たらせっかくなので Excelize の当該処理も修正したいところです。
利用しているOSSへ還元出来る絶好の機会なのでコントリビュートに挑戦しました。

正直、筆者は英語が得意ではないため、コード修正自体よりも「英語でIssueやPR descriptionを書く」ことの方がハードルに感じられました。
しかし、今はChatGPTやGemini等を活用すればそのハードルはぐっと下がっています。

  • IssueやPR説明が分かりやすいか言い回しのチェックとその英訳
  • CONTRIBUTING.mdに沿っているかのチェック

をAIにお願いしたことで、安心して Issue / PR を出すことができ、無事にマージされました。🎉

なお、社内のPRレビューではlen(utf16.Encode([]rune(s)))と書くのではなく、メモリ効率の良い1文字ずつ数える方法に修正した方が良いという意見を貰ったのに対し、ExcelizeへのPRでは1文字ずつ数える方法で出したところメンテナから、もっとシンプルに len(utf16.Encode([]rune(s))) と書くようにと指摘を受けました。
プロジェクトによって重視するポイントが違うのは面白いところですね。

最後に

今回のケースでは、自社プロダクトの不具合調査をきっかけに、
Excelの仕様や文字コードの扱いに対する理解を深めつつ、最終的にOSSへも還元できました。

OSSへのコントリビュートというと「大きな機能追加」や「高度な技術力」が必要だと思いがちですが、実際には今回のような小さな修正や改善でも大きな価値があります。
そして、その一歩は誰にでも踏み出せるものです。
英語や文化的な壁も、今はAIの力を借りればぐっと低くなります。

皆様も機会があれば挑戦してみてはいかがでしょうか。

KNOWLEDGE WORK Blog Sprint、明日の執筆者はフロントエンドエンジニア gomachanです。
お楽しみに!

脚注
  1. サロゲートペア:UTF-16において、本来16ビットでは表現できない、それより大きなUnicodeコードポイント(U+10000 以降)を2つのコードユニット(上位サロゲートと下位サロゲート)に分割して表現する仕組み。主に絵文字や一部の漢字などで利用される。 ↩︎

  2. 異体字セレクタ、合字、結合文字列:それぞれ、同じ漢字でも字体の違いを表現するための特殊な制御文字、複数の文字をデザイン上1つの文字のように見せるもの、文字と文字に付随する記号を組み合わせて1つの見た目を作る仕組みです。 ↩︎

株式会社ナレッジワーク

Discussion