ICSの75オクテット折り返しで日本語が化ける理由とセミコロンの書き忘れ

.ics ファイル(iCalendar)を自分で組み立てると、テキストの扱いで2つの問題に当たりやすい。1つは長い行の折り返し、もう1つは ; や , のエスケープである。

折り返しは RFC 5545 の 3.1 節で「1行は改行を除いて75オクテットを超えるべきではない(SHOULD NOT)」と決まっている。単位は文字ではなくオクテット(バイト)である。日本語は UTF-8 で1文字3バイトなので、length で75文字を数えると、実際には200バイト以上の行ができる。逆にバイト数で機械的に切ると、文字の途中で切れることがある。

この記事では、どちらの失敗も Node.js で再現し、読む側で文字化けが起きる条件を確かめる。あわせて、ハシトシステムの ICS ファイル生成 の実装も読み、見つかった問題を書く。

検証環境は Windows 11、Node.js v24.11.1、ical.js 2.2.1(Mozilla のカレンダー用ライブラリ)である。

折り返しの決まり

RFC 5545 の折り返しは次のとおりである。

  • 75オクテットを超える行は、途中に CRLF と空白1文字(SPACE か HTAB)を入れて分ける。
  • 読む側は「CRLF の直後に空白1文字」を見つけたら、その3バイトを取り除いて元の1行に戻す(unfold)。
  • 継続行の先頭の空白も1行の長さに含まれる。継続行に入れられる中身は74オクテットまでになる。

同じ節には、単純な実装は UTF-8 のマルチバイト文字の途中で折り返すことがあるので、読む側は元のバイト列を正しく戻せるように unfold する必要がある、という注記もある。書く側は文字の途中で切らないのが望ましく、読む側は切られていても戻せるようにしておけ、という意味である。

文字数で数えると折り返されない

次の件名で試す。

1
SUMMARY:第12回 フロントエンド勉強会 新宿会場 参加者向け案内(持ち物と集合場所)

この行は46文字だが、UTF-8 では112バイトある。line.length で75文字ごとに区切る実装では、46文字なので1度も折り返されず、112バイトの行がそのまま出る。

ical.js 2.2.1 は、この行を折り返さなくても問題なく読めた。SHOULD NOT なので、長い行を受け付ける実装は多い。ただし、どのアプリでも読めるとは限らないので、書く側は規定どおりにしておくほうがよい。

バイトで切ると文字が割れる

今度は UTF-8 のバイト列を75バイト目で機械的に切り、CRLF と空白を挟む。112バイトの行は75バイトと38バイト(先頭の空白を含む)の2行になる。75バイト目は「け」(3バイト)の途中にあたり、「け」の先頭2バイトが1行目に、残り1バイトが2行目に入る。

このファイルの読み方を3通り試した。

読み方 結果
1行ずつ UTF-8 として読んでから unfold する 参加者向��案内 になる
ファイル全体を UTF-8 として読んでから unfold する 同じく �� が入る
バイト列のまま unfold してから UTF-8 として読む 元の文字列と一致する

2つ目も壊れるのは、割れた「け」の間に CRLF と空白の3バイトが挟まっているためである。UTF-8 のデコーダーから見ると、続きのバイトが来るはずの位置に 0x0D が来るので、不正なバイト列として U+FFFD(置換文字)に置き換える。いったん U+FFFD になった文字は、後で CRLF と空白を取り除いても戻らない。

JavaScript で .ics を読むとき、fs.readFileSync(path, 'utf8') や await file.text() で文字列にしてから ical.js などのパーサーに渡すのが普通である。この順番だと、デコードが unfold より先になる。ical.js の ICAL.parse は文字列を受け取る関数なので、ここで割れた文字を直す手段はない。RFC の注記にある「元のバイト列を戻せるように」を満たすには、文字列にする前にバイト列で unfold する必要がある。

ical.js が書き出す継続行は76オクテット

ical.js 2.2.1 で長い値を書き出すと、ICAL.foldLength(既定75)にしたがって折り返す。helpers.foldline のソースでは、UTF-8 のバイト数を数えながらコードポイント単位で切っているので、文字の途中では割れない。

ただし、継続行の中身を75オクテットまで詰めてから先頭に空白を足している。DESCRIPTION: に ASCII の a を200文字入れて toString() すると、各行のバイト数は次のようになった。

1
[ 12, 75, 76, 63, 10 ]

BEGIN:VEVENT(12)の次の 75 が1行目、76 と 63 が継続行、10 が END:VEVENT である。継続行は空白を含めて76オクテットあり、RFC の75を1つ超える。読む側で困ることはまず無いが、厳密に検査するバリデーターに通すなら注意がいる。

エスケープが必要な文字

RFC 5545 の 3.3.11 節では、TEXT 型の値の中にある次の文字をバックスラッシュでエスケープすると決めている。

文字 書き方
\ \\
; \;
, \,
改行 \n(\N も可)

: はエスケープしない。また URI 型の値(URL プロパティなど)は 3.3.13 節でバックスラッシュによるエスケープを定義していないので、TEXT と同じ処理を通してはいけない。

ical.js 2.2.1 で次の2行を読むと、こうなった。

入力 読んだ値
SUMMARY:打合せ;議事録あり 打合せ;議事録あり
LOCATION:会議室A\, 3F\; 西側 会議室A, 3F; 西側
URL:https://example.com/a?x=1\,2&y=3 https://example.com/a?x=1\,2&y=3

ical.js は ; がエスケープされていなくても SUMMARY を1つの値として読んだ。一方、URL に入った \, は URI 型として扱われるので、バックスラッシュが残ったまま URL になる。この URL を開くとクエリが x=1\,2 になり、元のページに届かない。

ハシトシステムの ICS ファイル生成では

ICS ファイル生成 は、件名、日時、場所、説明、URL、繰り返し、通知を入力すると .ics ファイルを作るツールである。2026-11-12 に本番の HTML から escapeText と foldLine を取り出して、上と同じ入力を通した。

  • 折り返しは規定どおりだった。UTF-8 のバイト数を数え、サロゲートペアも1文字として扱い、1行目は75、継続行は空白を含めて75オクテット以内に収める。上の件名は73バイトと40バイトの2行になり、\r\n を取り除くと元に戻った。絵文字を含む行でも文字は割れなかった。
  • ; がエスケープされていなかった。escapeText は .replace(/;/g,'\;') と書かれているが、JavaScript の文字列リテラルでは '\;' は ';' と同じなので、; が ; に置き換わるだけになる。A;B,C を通すと A;B\,C になった。正しくは '\\;' である。
  • URL にも escapeText を通しているので、, を含む URL は \, になる。上の表のとおり、読む側では URL にバックスラッシュが残る。

ical.js のように ; のエスケープ漏れを許すパーサーでは件名が壊れることはないが、規定どおりではない。この2点は修正対象として記録した。

入力した予定の内容はブラウザ内で処理され、サーバーには送られない。予定を QR コードで配る方法は iCalendar(VEVENT) を QR にする記事 に書いた。バイト数の数え方そのものは 文字数カウント で確かめられる。

実際に試す

前提: Node.js(確認は v24.11.1)。外部パッケージは不要である。

書く側はコードポイント単位で UTF-8 のバイト数を数えて折り返す。読む側はバイト列のまま unfold してから UTF-8 として読む。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// ics-fold.mjs(Node.js v24.11.1 で確認・外部パッケージ不要)
// 使い方: node ics-fold.mjs
const enc = new TextEncoder();
const bytes = (s) => enc.encode(s).length;

// TEXT 値のエスケープ(RFC 5545 3.3.11)。バックスラッシュを最初に置換する
export function escapeText(s) {
return String(s)
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r\n|\r|\n/g, '\\n');
}

// 1 行を UTF-8 で 75 オクテット以内に折り返す。継続行は先頭の空白を含めて 75 以内
export function foldLine(line) {
const out = [];
let cur = '', curLen = 0;
for (const ch of line) { // for...of はコードポイント単位で回る
const w = bytes(ch);
const limit = out.length === 0 ? 75 : 74;
if (curLen + w > limit) { out.push(cur); cur = ''; curLen = 0; }
cur += ch; curLen += w;
}
out.push(cur);
return out.join('\r\n ');
}

// 読む側: バイト列のまま折り返しを戻してから UTF-8 として読む
export function unfoldBytes(buf) {
const out = [];
for (let i = 0; i < buf.length; i++) {
if (buf[i] === 0x0d && buf[i + 1] === 0x0a && (buf[i + 2] === 0x20 || buf[i + 2] === 0x09)) { i += 2; continue; }
out.push(buf[i]);
}
return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(out));
}

import { fileURLToPath } from 'node:url';
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const summary = '第12回 フロントエンド勉強会 新宿会場 参加者向け案内(持ち物と集合場所)';
const line = 'SUMMARY:' + escapeText(summary);
console.log('文字数', line.length, '/ バイト数', bytes(line));

const folded = foldLine(line);
console.log('折り返した各行のバイト数', folded.split('\r\n').map(bytes));

// 75 バイトで機械的に切った(文字の途中で割れた)ファイルを用意する
const raw = Buffer.from(line, 'utf8');
const naive = Buffer.concat([raw.subarray(0, 75), Buffer.from('\r\n '), raw.subarray(75)]);
const textFirst = new TextDecoder().decode(naive).replace(/\r\n[ \t]/g, '');
console.log('先に文字列にしてから戻す:', textFirst.includes('�') ? 'U+FFFD が入った' : '一致');
console.log('バイトのまま戻す :', unfoldBytes(naive) === line ? '一致' : '不一致');

console.log(escapeText('打合せ;議事録あり, 会議室A\\B\n2行目'));
const lsep = 'DESCRIPTION:' + escapeText('a'.repeat(200));
console.log('ASCII 200 文字', foldLine(lsep).split('\r\n').map(bytes));
}

実行結果は次のとおりである。

1
2
3
4
5
6
7
$ node ics-fold.mjs
文字数 46 / バイト数 112
折り返した各行のバイト数 [ 73, 40 ]
先に文字列にしてから戻す: U+FFFD が入った
バイトのまま戻す : 一致
打合せ\;議事録あり\, 会議室A\\B\n2行目
ASCII 200 文字 [ 75, 75, 64 ]

1行目が73バイトで止まっているのは、次の文字(3バイト)を足すと76になるからである。ASCII だけの行は75、75(空白1+中身74)、64 で、どの行も75を超えない。

unfoldBytes は TextDecoder に fatal: true を渡しているので、unfold した後もまだ不正な UTF-8 が残っていれば例外になる。黙って U+FFFD に置き換えて先へ進むことはない。ファイルから読むときは fs.readFileSync(path) のようにエンコーディングを指定せずに Buffer で受け取り、unfoldBytes に渡してから ical.js などのパーサーに文字列を渡す。

ical.js の継続行が76オクテットになることは、空のディレクトリで npm i ical.js@2.2.1 を実行してから次を打つと確かめられる。

1
2
node -e "const I=require('ical.js');const v=new I.Component('vevent');v.addPropertyWithValue('description','a'.repeat(200));console.log(v.toString().split('\r\n').map(s=>s.length))"
# [ 12, 75, 76, 63, 10 ]

まとめ

  • iCalendar の1行は改行を除いて75オクテットまで。文字数ではなく UTF-8 のバイト数で数え、継続行の先頭の空白も含める。
  • 日本語46文字の件名は112バイトある。length で数えると折り返されない。
  • バイト数で機械的に切ると文字が割れる。割れたファイルを先に UTF-8 の文字列にすると、unfold の前に U+FFFD に置き換わり、戻らない。読む側はバイト列のまま unfold してからデコードする。
  • ical.js 2.2.1 は文字を割らずに折り返すが、継続行は空白を含めて76オクテットになる。
  • TEXT 型では \、;、,、改行をエスケープする。JavaScript の '\;' は ';' なので、置換先は '\\;' と書く。
  • URL などの URI 型の値には TEXT のエスケープを通さない。通すと \, が URL に残る。

結果は 2026-11-12 時点の上記の環境とバージョンで得たものである。

参考

  • RFC 5545 — 3.1 Content Lines(折り返しと unfold、UTF-8 の注記)、3.3.11 Text、3.3.13 URI、3.8.4.6 Uniform Resource Locator
  • ical.js 2.2.1 のソース(dist/ical.js の foldline と foldLength)
  • MDN — TextDecoder(fatal オプション)、TextEncoder
  • WHATWG Encoding Standard — UTF-8 デコーダーのエラー処理(不正なバイト列を U+FFFD に置き換える)

ICSの75オクテット折り返しで日本語が化ける理由とセミコロンの書き忘れ
https://blog.hashito.biz/2026/11/12/ics-75-octet-line-folding-utf8-split-semicolon-escape/
著者
hashito
作成日
2026年11月12日
著作権

このバージョンはその範囲に入るのか

依存の話でいちばん間違えやすいのは ^1.2.3 や ~1.2.3 がどこまでを許すかです(^0.2.3 のようにメジャーが 0 のときは規則が変わります)。範囲とバージョンを貼ると、下限・上限に展開したうえで一致・不一致とその理由を表示するsemver 範囲判定ツールを置いています。ブラウザの中だけで動き、入力はどこにも送信しません。