JavaRush /Курси /Kotlin SELF /BOM — як розпізнати і чому він ламає файл

BOM — як розпізнати і чому він ламає файл

Kotlin SELF
Рівень 44 , Лекція 3
Відкрита

1. BOM: що це і навіщо він існує

Якщо ви коли-небудь потрапляли в ситуацію «рядок виглядає однаково, але == каже „ні“», то вітаю: ви познайомилися з класом проблем, у яких винні невидимі символи. BOM (Byte Order Mark) — один із найпопулярніших «ніндзя» в цій категорії. Він може зʼявитися на початку текстового файлу й ніяк не видати себе під час звичайного перегляду. А потім — раптово зламати розбір, заголовки та порівняння.

BOM — це службова мітка: набір спеціальних байтів на самому початку файлу. Історично її використовували, щоб підказати «яке кодування» і/або «який порядок байтів» (особливо це стосується UTF‑16). Важливо: BOM не є даними користувача. Це не «частина тексту», яку людина хотіла зберегти. Але якщо ви наївно читаєте файл як рядок, а потім порівнюєте «перше слово», BOM перетворюється на пісок у шестернях.

Уявіть, що ви очікуєте на вході паспорт («HEADER»), а вам підсовують паспорт із невидимою наліпкою поверх першої літери. Візуально це той самий документ, але формально — уже ні.

Як BOM ламає початок файлу

На практиці BOM найчастіше проявляється так: перший рядок або перший токен „не збігається“, хоча в логах або в редакторі все виглядає однаково. Особливо боляче це бʼє по форматах «перший рядок — заголовок» або «файл починається з магічної сигнатури».

Уявімо наш навчальний CLI‑проєкт: список завдань, який зберігається в текстовому файлі. Ми домовилися, що перший рядок файлу має бути заголовком:

TODO_V1

А далі йдуть рядки завдань (формат зараз не важливий — це просто текст).

fun main() {
    val expectedHeader = "TODO_V1"
    val actualHeader = "\uFEFFTODO_V1"

    println(expectedHeader == actualHeader) // false
}

З погляду людини це «одне й те саме». З погляду програми — ні, тому що \uFEFF на початку робить рядок іншим.

І ось ви пишете чесну перевірку:

fun isValidHeader(header: String): Boolean {
    return header == "TODO_V1"
}

…а вона починає повертати false «без причини». Причина є. Просто вона невидима — як баги в продакшн-середовищі.

Як виявити BOM за байтами

Коли йдеться про BOM, дуже корисно памʼятати про межу: на диску — байти, а рядок зʼявляється потім. Якщо ми хочемо впевнено розпізнати BOM, логічно спершу подивитися на перші байти файлу. Для UTF‑8 BOM — класичний префікс із трьох байтів:

EF BB BF

Щоб перевіряти це в Kotlin, нам потрібен ByteArray. Це як IntArray, тільки для байтів: масив примітивів, без «обгорток». Kotlin прямо виділяє такі масиви в окремі типи (зокрема ByteArray).

Мініперевірка UTF‑8 BOM за першими трьома байтами:

fun hasUtf8Bom(bytes: ByteArray): Boolean {
    return bytes.size >= 3 &&
        bytes[0] == 0xEF.toByte() &&
        bytes[1] == 0xBB.toByte() &&
        bytes[2] == 0xBF.toByte()
}

Зверніть увагу на два практичні моменти.

По-перше, ми обовʼязково перевіряємо bytes.size >= 3. BOM може й не бути, файл може бути порожнім, файл може складатися з двох байтів — і якщо забути про перевірку, ви отримаєте виняток із серії «індекс вийшов за межі масиву». А винним буде зовсім не BOM, а ваш поспіх.

По-друге, ми порівнюємо з toByte(), тому що літерали 0xEF — це Int, а в масиві лежать Byte. Kotlin тут змушує бути чесним: хочете порівнювати — зводьте типи явно.

Як BOM виглядає в рядку: '\uFEFF'

Іноді вам не хочеться лізти в байти. Наприклад, ви вже прочитали файл через readText(Charsets.UTF_8) і отримали рядок, а десь далі несподівано руйнується розбір заголовка. У таких випадках BOM може проявитися як перший символ рядка '\uFEFF'.

Важливо розуміти: це не «якийсь пробіл». І це не обовʼязок trim() — прибирати BOM. trim() працює з пробільними символами та переносами, але BOM — окрема сутність. Тож поведінка тут легко виявиться не такою, на яку ви сподівалися.

Перевірка «чи є BOM‑символ на початку рядка»:

fun startsWithBomChar(text: String): Boolean {
    return text.isNotEmpty() && text[0] == '\uFEFF'
}

І обережне видалення:

fun dropLeadingBomChar(text: String): String {
    return if (text.isNotEmpty() && text[0] == '\uFEFF') {
        text.substring(1)
    } else {
        text
    }
}

Ключове слово тут — обережне. Ми не «завжди відрізаємо перший символ», а робимо це лише тоді, коли впевнено розпізнали символ BOM.

Які BOM бувають: мінімум для практики

Зараз важливо не вивчити Unicode «назубок», а сформувати практичну інтуїцію: BOM — це префікс, який може мати різну довжину. Ми сфокусуємося на найчастішому випадку (UTF‑8), але корисно знати, що UTF‑16 теж любить починати файл зі службових байтів.

Кодування (часті випадки) BOM у hex (на початку файлу) Довжина
UTF‑8
EF BB BF
3 байти
UTF‑16 LE / BE бувають варіанти (часто 2 байти) 2 байти

Чому я не розписую всі варіанти UTF‑16 детально? Тому що мета лекції — навчитися розпізнавати сам факт BOM і його ефект, а не влаштовувати іспит «вгадай endianness». Нам достатньо такого: «у UTF‑8 це 3 байти EF BB BF, а в інших кодуваннях теж можуть бути службові байти». Це вже заощаджує купу часу під час налагодження.

Як видаляти BOM правильно: спочатку перевіряємо

Зараз буде момент, у якому новачок часто робить «різкий і красивий» хід, а потім тиждень ловить примар: «давайте завжди відкинемо перші 3 байти!» — і «проблема BOM зникне».

Так, зникне. Разом із першими трьома байтами реальних даних у тих файлах, де BOM не було. Тобто ви полагодите один сценарій, а зламаєте десять інших — просто менш помітних.

Правильна ідея така: спочатку перевірити, потім видалити.

Видалення UTF‑8 BOM на рівні байтів:

fun dropUtf8Bom(bytes: ByteArray): ByteArray {
    return if (hasUtf8Bom(bytes)) {
        bytes.copyOfRange(3, bytes.size)
    } else {
        bytes
    }
}

ByteArray — це той самий «примітивний масив» байтів, з яким зручно робити такі операції.

Вбудовуємо BOM‑перевірку в проєкт із завданнями

Тепер зберемо все в мініісторію, максимально схожу на реальне життя. Нехай у нас файл data/todo.txt має починатися із заголовка TODO_V1. Ми читаємо перший рядок і порівнюємо.

Для простоти зробимо функцію, яка бере перший рядок із тексту:

fun firstLine(text: String): String {
    val idx = text.indexOf('\n')
    return if (idx == -1) text else text.substring(0, idx)
}

Тепер читаємо файл у UTF‑8 (як ми домовилися в минулій лекції — явно задаємо кодування) і валідуємо заголовок:

import java.io.File

fun main() {
    val text = File("data/todo.txt").readText(Charsets.UTF_8)
    val header = firstLine(text)

    println("header='$header'") // може бути з BOM на початку
    println(header == "TODO_V1") // false, якщо header = "\uFEFFTODO_V1"
}

Проблема тут у тому, що readText(Charsets.UTF_8) чесно прочитав файл як UTF‑8. Але якщо редактор або утиліта додали BOM, він перетворюється на символ у рядку — і ви отримуєте «невидимий префікс».

Виправлення «на рівні рядка» виглядатиме так: очищаємо заголовок від символа BOM перед порівнянням.

import java.io.File

fun main() {
    val text = File("data/todo.txt").readText(Charsets.UTF_8)
    val header = dropLeadingBomChar(firstLine(text))

    println(header == "TODO_V1") // true
}

Це вже набагато краще, ніж «відрізати завжди», тому що ми видаляємо символ лише за умови.

Діагностика: як довести, що це BOM

Коли ви підозрюєте BOM, дуже хочеться «побачити докази». І тут у нас є два хороші прийоми: подивитися перші байти або вивести код першого символу рядка. Обидва варіанти корисні, тому що іноді ви читаєте файл не тим способом — і симптом змінюється.

Спочатку надрукуємо перші 6 байтів у hex‑вигляді (мінімальний варіант, без складних форматерів):

fun firstBytesHex(bytes: ByteArray, n: Int): String {
    val count = minOf(n, bytes.size)
    return bytes.take(count).joinToString(" ") { b ->
        "%02X".format(b.toInt() and 0xFF)
    }
}

А тепер застосуємо:

import java.io.File

fun main() {
    val bytes = File("data/todo.txt").readBytes()
    println(firstBytesHex(bytes, 6)) // наприклад: EF BB BF 54 4F 44
}

Якщо ви побачили EF BB BF, то майже напевно це UTF‑8 BOM. Далі йдуть байти 54 4F 44 (це ASCII/UTF‑8 для T O D).

Якщо ви вже читаєте рядок і хочете перевірити символ BOM:

fun main() {
    val s = "\uFEFFTODO_V1"
    println(s[0].code) // 65279
}

Число 65279 — це код U+FEFF. Це і є той самий «невидимий пасажир».

BOM ламає не лише ваш код

Іноді здається: «Ну це я криво парсю». Але BOM уміє ламати й інструменти. Наприклад, у changelog Kotlin згадували проблеми з UTF‑8 BOM у Kotlin scripting (тобто не лише ви страждаєте — страждають і дорослі люди з компіляторами).

Це корисно памʼятати з двох причин. Перша — ви перестаєте відчувати себе єдиною людиною на планеті, яка «зламала рядок». Друга — ви починаєте ставитися до BOM як до реального зовнішнього фактора, який потрібно враховувати під час роботи з файлами. Особливо якщо файли можуть приходити з різних редакторів, ОС і інструментів.

2. Типові помилки під час роботи з BOM

Помилка № 1: «відріжемо перший символ (або перші 3 байти) завжди — і готово».
Це дуже спокусливо, бо виглядає як швидкий фікс. Але так ви починаєте пошкоджувати коректні файли без BOM. Найнеприємніше те, що пошкодження може бути не одразу помітним: заголовок «полагодився», а от перше завдання або перша літера першого рядка — зникла. Потрібно видаляти BOM лише після перевірки, що він справді є.

Помилка № 2: плутанина рівнів «байти vs рядок».
Часто роблять так: файл уже прочитаний як String, але потім намагаються «перевірити перші байти» — і починають городити дивні конструкції, які порівнюють Char з 0xEF. Це різні сутності. Якщо ви хочете надійно перевірити UTF‑8 BOM як EF BB BF, робіть це на ByteArray. Якщо ви хочете швидко прибрати «невидимий символ» на початку рядка — перевіряйте '\uFEFF'. Важливо просто не змішувати ці рівні в одній перевірці.

Помилка № 3: очікування, що trim() зобовʼязаний прибрати BOM.
trim() — це не «універсальна швабра для всіх невидимих проблем». Він не призначений для видалення BOM. І навіть якщо в якомусь оточенні це випадково допомогло, це не контракт, на який варто спиратися. Для BOM краще мати явну перевірку на '\uFEFF' або перевірку перших байтів.

Помилка № 4: відсутність перевірки довжини масиву байтів.
Перевірка BOM за байтами майже завжди починається з bytes.size >= 3 (для UTF‑8 BOM). Якщо забути цю умову, на порожньому файлі або на короткому файлі ви отримаєте падіння за індексом. І це буде особливо дратувати, бо падіння станеться «на діагностиці», а не в реальній логіці.

Помилка № 5: спроба лікувати BOM «підбором іншого кодування».
Іноді пробують: «а давайте прочитаємо UTF‑16… а давайте прочитаємо ASCII…». Це може перетворити текст на «кракозябри», але BOM не зникне як явище. BOM — це окремий шар проблеми: службовий префікс у файлі. Кодування важливе, але BOM треба або коректно обробити на вході, або прибрати як службовий маркер — а не намагатися «вгадувати» кодування доти, доки не стане гарно.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ