{"slug": "the-icalendar-spec-says-75-octets-not-75-characters", "title": "The iCalendar Spec Says 75 Octets, Not 75 Characters", "summary": "A developer running AI Change Watch, a project that publishes calendar feeds for AI model shutdowns, discovered that the iCalendar spec's 75-octet line limit broke Japanese-language feeds because UTF-8 Japanese characters are multi-byte. The fix required counting bytes but folding at character boundaries, handling continuation-line limits, and using CRLF line endings. The developer also keyed UIDs to model slugs rather than localized summaries to prevent duplicate calendar entries across languages.", "body_md": "I run [ AI Change Watch](https://aichangewatch.com), a small independent project that\n\nOne of the things it publishes is a subscribable calendar: every announced model shutdown, as `.ics`\n\n,\n\nso a date the vendor moves updates in your calendar instead of in a changelog you forgot to read.\n\nThe English feed worked immediately. The Japanese one was rejected.\n\nSame code. Same events. The only difference was the language of the text inside.\n\nSection 3.1:\n\nLines of text SHOULD NOT be longer than 75\n\noctets, excluding the line break.\n\nNot 75 characters. **75 octets.**\n\nFor ASCII those are the same number, which is exactly why this survives every test you are likely to\n\nwrite. `SUMMARY:gpt-4-32k shutdown (OpenAI)`\n\nis 36 characters and 36 bytes. Nothing to notice.\n\nThen the same field comes back translated:\n\n```\nSUMMARY:code-davinci-001 提供終了（OpenAI）\n```\n\nThat is 30 characters. It is **49 octets** — Japanese runs about 3 bytes per character in UTF-8. A\n\nfolder that counts characters looks at 30, decides no fold is needed, and emits a line that is legal by\n\nits own arithmetic and illegal by the spec's.\n\nLonger titles cross 75 octets while still well under 75 characters, and that is the line the parser\n\nrejects.\n\nThe obvious fix — count bytes instead — introduces a second one if you write it the obvious way.\n\n``` js\n// Still broken.\nconst bytes = Buffer.from(line, 'utf8');\nfor (let i = 0; i < bytes.length; i += 75) {\n  out.push(bytes.subarray(i, i + 75).toString('utf8'));\n}\n```\n\nSlicing a UTF-8 buffer at a fixed offset **cuts through the middle of a character**. Byte 75 lands in\n\nthe second of the three bytes that make up 終, and you emit half a codepoint on one line and the other\n\nhalf on the next. Some parsers replace it with U+FFFD, some abort the file.\n\nSo the fold has to be counted in bytes but *taken* at character boundaries:\n\n``` js\nexport function foldLine(line) {\n  const enc = new TextEncoder();\n  if (enc.encode(line).length <= 75) return line;\n\n  const out = [];\n  let cur = '', curBytes = 0;\n  // The first line gets 75 octets; every continuation gets 74, because the leading\n  // space that marks it as a continuation counts toward the limit too.\n  let limit = 75;\n\n  for (const ch of line) {          // iterating a string yields whole codepoints\n    const n = enc.encode(ch).length;\n    if (curBytes + n > limit) {\n      out.push(cur);\n      cur = ''; curBytes = 0; limit = 74;\n    }\n    cur += ch;\n    curBytes += n;\n  }\n  if (cur) out.push(cur);\n\n  return out.map((l, i) => (i === 0 ? l : ` ${l}`)).join('\\r\\n');\n}\n```\n\nThree things in there are easy to leave out and each one produces a file that mostly works:\n\n** for (const ch of line), not line[i].** Indexing a JS string walks UTF-16 code units, so an emoji\n\n`for...of`\n\nyields whole**The limit drops from 75 to 74 after the first line.** The continuation marker is a single leading\n\nspace and it counts. Keep the limit at 75 and every folded line is one octet over — which is the same\n\nbug you just fixed, only harder to see because it only shows up on lines long enough to fold.\n\n** \\r\\n, not \\n.** RFC 5545 wants CRLF. Plenty of parsers tolerate bare LF, right up until one\n\nAn `.ics`\n\nfeed served in ten languages raises a question the spec answers but does not warn you about:\n\nwhat is the `UID`\n\n?\n\nIf you build it from the localized summary, the same shutdown gets a different UID in every language.\n\nSubscribe to two of them and your calendar shows **two entries for one event**, forever, with no way to\n\ntell they are the same thing.\n\nSo the UID has to be keyed on the *entity*, never the presentation:\n\n```\nUID:shutdown-o1-preview@aichangewatch.com\nUID:shutdown-davinci-002@aichangewatch.com\n```\n\nModel slug, no locale. I checked the two live feeds while writing this:\n\n```\nen feed:  262 UIDs\nja feed:  262 UIDs\nshared:   262            ← identical sets\n```\n\nWhich means a person subscribed to both gets one entry per shutdown, not two. The second subscription\n\n*overwrites* the first rather than duplicating it. That is the correct failure mode — a collision beats\n\na double-booking, because a duplicate calendar entry is something the user has to notice and clean up by\n\nhand.\n\nIt also means you cannot use the UID to carry the language. If you need that, it belongs in the calendar\n\nname (`X-WR-CALNAME`\n\n), not the identity.\n\nThe whole class is \"a spec said octets and I read characters\", and it is worth one assertion in a test\n\nrather than a careful reading:\n\n``` js\nconst body = renderCalendar(events);\nfor (const line of body.split('\\r\\n')) {\n  expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(75);\n}\n```\n\nRun it against the **localized** feed, not the English one. The English feed cannot fail this test,\n\nwhich is precisely why it is not the one to run it on. Mine currently reports:\n\n```\nen feed:  6,545 lines,   0 over 75 octets,  506 continuation lines\nja feed:  6,606 lines,   0 over 75 octets,  567 continuation lines\n```\n\nThe 61 extra lines in the Japanese feed are the folds the English one does not need. That gap *is* the\n\nbug, made visible: same events, same code, more lines — because the same sentences take more bytes.\n\n*The tracker this came out of is at aichangewatch.com — the\nshutdown calendar it generates is at\n/deprecations/calendar.ics.*", "url": "https://wpnews.pro/news/the-icalendar-spec-says-75-octets-not-75-characters", "canonical_source": "https://dev.to/ai_changewatch/the-icalendar-spec-says-75-octets-not-75-characters-5cn8", "published_at": "2026-08-27 12:00:00+00:00", "updated_at": "2026-08-27 12:18:52.833890+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["AI Change Watch"], "alternates": {"html": "https://wpnews.pro/news/the-icalendar-spec-says-75-octets-not-75-characters", "markdown": "https://wpnews.pro/news/the-icalendar-spec-says-75-octets-not-75-characters.md", "text": "https://wpnews.pro/news/the-icalendar-spec-says-75-octets-not-75-characters.txt", "jsonld": "https://wpnews.pro/news/the-icalendar-spec-says-75-octets-not-75-characters.jsonld"}}