やりたかったこと
インストール不要・オフラインで動く業務ツールを、HTMLファイル1つで配りたい。
利用者はブラウザでダブルクリックして開くだけ。サーバーもビルド環境も無し。
そこに「Excelファイルを読み込む」機能が必要になりました。
普通なら SheetJS を使います。実際それが正解な場面は多いです。
ただ今回は次の制約がありました。
- CDNを読めない(オフラインで動かしたい。社内ネットワークが閉じている利用者もいる)
- ビルドしない(HTML1枚で完結させたい。npmもバンドラも使わない)
- ライブラリをインラインに埋め込むとファイルが数百KB増える
そこで、必要な範囲だけ自分で書くことにしました。結論から言うと、
DecompressionStream と DOMParser があれば、実用範囲は自前で読めます。
正直に書いておくと、最初は「ZIPを自分で開く」と聞いた時点で無理だと思っていました。
実際にやってみたら、必要だったのはヘッダの数バイトを正しい順番で読むことだけでした。
難しかったのはそこではなく、この後に出てくる日付です。
.xlsx の正体は ZIP + XML
まず前提。.xlsx は ZIPアーカイブで、中身はXMLです。展開するとこうなっています。
xl/workbook.xml シート名の一覧
xl/_rels/workbook.xml.rels シート名 → 実ファイルの対応
xl/worksheets/sheet1.xml セルの中身
xl/sharedStrings.xml 文字列の実体(セルは番号で参照する)
xl/styles.xml 表示形式(日付かどうかの判定に使う)
やることは3つだけです。
- ZIPを展開する
- XMLをパースする
- Excel特有の値(共有文字列・日付シリアル値)を復元する
1. ZIPを展開する
ZIPの構造を全部実装する必要はありません。セントラルディレクトリを読んで、各エントリの位置を拾うだけで足ります。
async function unzip(buf){
const dv = new DataView(buf), u8 = new Uint8Array(buf);
// End of Central Directory を末尾から探す(シグネチャ 0x06054b50)
let eocd = -1, min = Math.max(0, u8.length - 65558);
for(let i = u8.length - 22; i >= min; i--){
if(dv.getUint32(i, true) === 0x06054b50){ eocd = i; break; }
}
if(eocd < 0) throw new Error('ZIPとして読めません');
const nEnt = dv.getUint16(eocd + 10, true); // エントリ数
const cdOff = dv.getUint32(eocd + 16, true); // セントラルディレクトリの位置
const files = {}; let p = cdOff;
for(let i = 0; i < nEnt; i++){
if(dv.getUint32(p, true) !== 0x02014b50) break;
const method = dv.getUint16(p + 10, true); // 0=無圧縮 8=deflate
const compSize = dv.getUint32(p + 20, true);
const nLen = dv.getUint16(p + 28, true);
const eLen = dv.getUint16(p + 30, true);
const cLen = dv.getUint16(p + 32, true);
const lho = dv.getUint32(p + 42, true); // ローカルヘッダの位置
const name = new TextDecoder('utf-8').decode(u8.subarray(p + 46, p + 46 + nLen));
// 実データの開始位置はローカルヘッダを読まないと分からない
const lnLen = dv.getUint16(lho + 26, true);
const leLen = dv.getUint16(lho + 28, true);
files[name] = { method, start: lho + 30 + lnLen + leLen, size: compSize };
p += 46 + nLen + eLen + cLen;
}
return { u8, files };
}
ハマりどころが1つ。実データの開始位置は、セントラルディレクトリだけでは決まりません。
ローカルファイルヘッダのファイル名長・拡張フィールド長を読んで足す必要があります。
展開は DecompressionStream に任せる
deflateの実装を自分で書く必要はありません。ブラウザ標準の DecompressionStream('deflate-raw') が使えます。
async function readEntry(z, name){
const f = z.files[name];
if(!f) return null;
const slice = z.u8.subarray(f.start, f.start + f.size);
if(f.method === 0) return new TextDecoder('utf-8').decode(slice); // 無圧縮
if(f.method !== 8) throw new Error('未対応の圧縮方式です');
if(typeof DecompressionStream === 'undefined'){
throw new Error('このブラウザでは解凍できません');
}
const stream = new Blob([slice]).stream()
.pipeThrough(new DecompressionStream('deflate-raw'));
return new TextDecoder('utf-8').decode(await new Response(stream).arrayBuffer());
}
deflate ではなく deflate-raw です。ZIPの中身はzlibヘッダを持たない生のdeflateなので、
ここを間違えるとエラーになります。
対応状況は Chrome / Edge 103+、Firefox 113+、Safari 16.4+。
未対応環境にはCSV読み込みへ誘導するフォールバックを用意しました。
2. XMLをパースする
DOMParser で読めます。正規表現でXMLを処理しないこと(属性の順序や名前空間で必ず破綻します)。
const dom = s => new DOMParser().parseFromString(s, 'application/xml');
共有文字列(sharedStrings.xml)
セルに文字列が入っている場合、実体はここにあり、セルはインデックスだけを持ちます。
const sst = [];
const sx = await readEntry(z, 'xl/sharedStrings.xml');
if(sx) dom(sx).querySelectorAll('si').forEach(si => {
let t = '';
si.querySelectorAll('t').forEach(n => {
// ルビ(rPh)の中の t は本文ではないので除く
if(n.parentNode && n.parentNode.nodeName !== 'rPh') t += n.textContent;
});
sst.push(t);
});
ルビの除外は日本語特有のハマりどころです。
Excelでふりがな付きのセルがあると <rPh> の中にも <t> があり、
素直に全部連結すると「山田太郎ヤマダタロウ」のような文字列になります。
シート名 → 実ファイルの対応
workbook.xml のシート順と sheet1.xml の番号は一致しません。
workbook.xml.rels を経由して解決します。
const rels = {};
const rx = await readEntry(z, 'xl/_rels/workbook.xml.rels');
if(rx) dom(rx).querySelectorAll('Relationship')
.forEach(r => rels[r.getAttribute('Id')] = r.getAttribute('Target'));
const sheets = [];
const wx = await readEntry(z, 'xl/workbook.xml');
if(wx) dom(wx).querySelectorAll('sheet').forEach((s, i) => {
const rid = s.getAttribute('r:id') || s.getAttribute('id');
let t = rels[rid] || '';
if(t){ t = t.replace(/^\.?\//, ''); if(t.indexOf('xl/') !== 0) t = 'xl/' + t; }
sheets.push({ name: s.getAttribute('name'), path: t || `xl/worksheets/sheet${i+1}.xml` });
});
3. Excel特有の値を復元する
ここが一番の落とし穴です。
セルの読み取り
function colIdx(ref){ // "BC12" → 54
const m = String(ref).match(/^([A-Z]+)/);
if(!m) return -1;
let n = 0;
for(const c of m[1]) n = n * 26 + (c.charCodeAt(0) - 64);
return n - 1;
}
// row の中の c を読む
r.querySelectorAll('c').forEach(c => {
const col = colIdx(c.getAttribute('r') || '');
const t = c.getAttribute('t');
const si = +(c.getAttribute('s') || -1); // スタイル番号
let v = '';
if(t === 'inlineStr'){
const is = c.querySelector('is'); v = is ? is.textContent : '';
} else {
const vn = c.querySelector('v');
const raw = vn ? vn.textContent : '';
if(t === 's') v = sst[+raw] ?? ''; // 共有文字列
else if(t === 'str' || t === 'e' || t === 'b') v = raw;
else v = (raw !== '' && si >= 0 && dateStyle[si]) // 数値
? serialToStr(+raw) : raw;
}
if(col >= 0) arr[col] = v; else arr.push(v);
});
空のセルはXMLに出てきません。 だから arr[col] = v のように
列インデックスで代入しないと、列がずれます。順番にpushしてはいけません。
日付は「数値」として入っている
これが一番ハマりました。Excelの日付は数値です。2026-08-05 は 46239 のような値で保存されています。
見分けるには styles.xml の表示形式を読むしかありません。
最初、画面に 46239 がずらっと並んだときは、何を間違えたのか本気で分かりませんでした。
XMLのどこを探しても「2026-08-05」という文字列は入っていない。当たり前です、数値なんですから。
セルの値ばかり睨んでいても答えは出ません。書式のほうを見に行く必要がある。
目の前の数字だけ見て原因を探しても見つからない、というのはよくある話で、
ここでも同じことをやっていました。気づくまでが長かったです。
const dateStyle = [];
const stx = await readEntry(z, 'xl/styles.xml');
if(stx){
const d = dom(stx), custom = {};
d.querySelectorAll('numFmt').forEach(n =>
custom[n.getAttribute('numFmtId')] = n.getAttribute('formatCode') || '');
const builtin = [14,15,16,17,18,19,20,21,22,45,46,47]; // 組み込みの日付/時刻書式
d.querySelector('cellXfs')?.querySelectorAll('xf').forEach(xf => {
const id = +(xf.getAttribute('numFmtId') || 0);
let isDate = builtin.includes(id);
if(!isDate && custom[id] != null){
// [赤] や "円" を除いてから y/m/d/h/s を探す
const fc = custom[id].replace(/\[[^\]]*\]/g, '').replace(/"[^"]*"/g, '');
isDate = /[ymdhs]/i.test(fc);
}
dateStyle.push(isDate);
});
}
ユーザー定義書式(numFmtId >= 164)は formatCode を見て判定します。
このとき [$-409] のような角括弧や、"円" のようなリテラルを先に除かないと、
d(day)や h(hour)を誤検出します。
そして復元。
function serialToStr(n){
const days = Math.floor(n), frac = n - days;
const secs = Math.round(frac * 86400);
const hh = Math.floor(secs / 3600), mm = Math.floor((secs % 3600) / 60);
const pad = x => String(x).padStart(2, '0');
if(days <= 0) return pad(hh) + ':' + pad(mm); // 時刻のみ(0.375 = 09:00)
// 起点は 1899-12-30(1900年うるう年バグを吸収した値)
const d = new Date(Date.UTC(1899, 11, 30));
d.setUTCDate(d.getUTCDate() + days);
const ds = `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())}`;
return frac > 1e-7 ? `${ds} ${pad(hh)}:${pad(mm)}` : ds;
}
起点が 1899-12-30 なのは、Excelが「1900年を閏年とみなす」という歴史的なバグを持っているためです。
1900-01-01 を起点にすると1日ずれます。
実際に使ってみて
実在する勤務表(見出しが5行目から始まり、氏名が表の外のセルにあり、
休憩が「開始・終了」の3組で入り、日付が 7/1(水) 形式)を読み込ませ、
月の労働時間合計が元の表と完全に一致することを確認しました。
計算ロジックとパース部分は74項目の自動テストを書いて検証しています。
ヘッドレスブラウザでテストHTMLを開いてDOMを吐かせ、結果を突き合わせる形にしました。
自作して良かったこと
- 依存ゼロ。HTML1枚で完結し、オフラインで動く
- 追加サイズは約6KB(SheetJSのフルビルドは数百KB)
- 挙動が完全に把握できる。おかしな値が出たとき原因を追える
自作を勧めない場合
- 書き込みが必要なら素直にライブラリを使うべきです(今回は読み取り専用)
- 数式の評価、グラフ、ピボット、条件付き書式が必要なら同上
-
.xls(BIFF形式)は別物です。今回は非対応にして、
「Excelで .xlsx かCSVとして保存し直してください」と案内するようにしました - Zip64(4GB超)も未対応です
「読み取りだけ」「セルの値だけ」なら、この程度で足ります。
逆に言えば、それ以上を求めるならライブラリのほうが確実です。
まとめ
-
.xlsxは ZIP + XML。展開はDecompressionStream('deflate-raw')に任せられる - ZIPはセントラルディレクトリ+ローカルヘッダを読めば、実データの位置が分かる
- 文字列は
sharedStrings.xmlを参照。ルビ(rPh)を除くこと - 空セルはXMLに存在しないので、列インデックスで代入する
-
日付は数値。
styles.xmlの書式を見て判定し、起点 1899-12-30 で復元する
「ライブラリを使わない」こと自体が目的ではありません。
オフラインで動く1ファイルのツールを配りたい、という制約から自然にこうなりました。
もともと私は、現場で毎月おなじ集計に時間を取られている側の人間でした。
だから「利用者に何も準備させない」ことを最優先にしています。
インストールも、アカウント登録も、ネット接続も要らない。ファイルを開けば動く。
そのために自分で書く必要があるなら、書くしかない——という順番でここに行き着きました。
派手な技術ではありません。ヘッダを数バイト読んで、XMLを開いて、日付を戻すだけです。
ただ、その地味な積み重ねの先に「開けば動く1ファイル」ができました。
同じ制約に当たった人の参考になれば幸いです。