Расширение врёт: как я учил Android-приложение открывать двести типов файлов по первым байтам
- воскресенье, 8 ноября 2026 г. в 00:00:09
У каждого файла два имени. Первое написано после точки, и верить ему нельзя: его придумал человек, почтовый клиент или сайт, который «на всякий случай» переименовал вложение. Второе лежит в первых байтах. Вот оно почти никогда не врёт.
Я делаю XanderFiles — просмотрщик для Android, который открывает двести с лишним расширений: от PDF и Excel до winmail.dat, подписанных .p7m, RAR с паролем, книг FB2, RAW с фотоаппаратов, билетов Wallet, снимков КТ с диска из клиники и рисунков CorelDRAW. Это статья не о том, как меня достала надпись «Не удаётся открыть файл». Она о том, как всё устроено внутри и на какие грабли я наступил. Будут WebAssembly, матрёшка из Outlook, правка чужого минифицированного кода на лету и картинки, которые никто не рисует.
Первый вопрос знакомых разработчиков: «Двести форматов — это же двести нативных библиотек?» Нет. Почти весь просмотр живёт в WebView. Android-часть — это окно, выдача файла странице и пара вещей, которым в браузере тесно.
Причин три.
Готовые движки. pdf.js, SheetJS, mammoth.js, JSZip годами шлифовались в браузере. Писать свой рендер PDF я не собирался.
Выделение текста бесплатно. Хотелось, чтобы текст можно было выделить и скопировать везде: в PDF, в письме, в таблице, на распознанном скане. В вебе это поведение по умолчанию.
Всё проверяется на компьютере. Веб-часть запускается в обычном браузере с имитацией Android (node tools/devserver.js), поэтому каждый формат можно прогнать автотестом в Playwright, не трогая телефон.
Нативным осталось то, где браузер не тянет: распознавание текста (Tesseract) и аудиоплеер (Media3 + FFmpeg). В цифрах: около 22 тысяч строк своего JavaScript без переводов, 26 просмотрщиков и около 11 тысяч строк Java, большая часть которых — звук.
У подхода есть цена, и я её не прячу. APK весит 64 МБ. 29 из них — модели распознавания для 12 языков, ещё около 12 — библиотеки, включая 7-Zip и декодер JPEG XL в WebAssembly. Можно было бы качать модели по требованию, но тогда приложению нужен интернет. А я хотел, чтобы оно работало в самолёте.
Определить формат по сигнатуре — задача на вечер. Сделать так, чтобы определение не ошибалось на реальных файлах, — задача на месяц. Всё упирается в порядок проверок: многие форматы — контейнеры, внутри которых лежат другие форматы со своими сигнатурами.
Вот начало detect.js, если убрать подробности:
// DICOM — первым: «DICM» стоит на смещении 128, а сами 128 байт бывают заголовком TIFF // ZIP-контейнеры — раньше PDF: внутри несжатого ZIP может лежать PDF // RAW с фотоаппаратов — раньше TIFF и HEIC: многие RAW — это TIFF, CR3 — ISO-BMFF // winmail.dat — раньше PDF: в первом же килобайте бывает PDF-вложение // FIT (часы Garmin) — «.FIT» на месте 8 // CMS/PKCS#7 — раньше PDF: внутри .pdf.p7m сразу идёт PDF // архивы с подписью в начале — раньше PDF // PDF — сигнатура может стоять не в самом начале // EPS — после PDF: «%!PS-Adobe-3.0 EPSF» или двоичный заголовок C5 D0 D3 C6 // OLE — старые Word/Excel, Outlook .msg, Microsoft Works и… зашифрованные новые Office // Kindle/Palm — тип и создатель по смещению 60 // …картинки, звук, видео… // TGA и LZMA — подписи нет вообще, только расширение и правдоподобный заголовок
Почти каждая строчка здесь — история одной ошибки.
Самая коварная — %PDF-. По стандарту PDF может начинаться не с первого байта, поэтому его ищут в первом килобайте. А там может оказаться что угодно: несжатый PDF внутри ZIP, PDF-вложение внутри winmail.dat, PDF внутри подписанного .pdf.p7m. Если проверить PDF раньше контейнера, приложение радостно откроет вложенный документ и молча потеряет всё остальное: подпись, другие вложения, имена файлов. Поэтому все контейнеры проверяются до PDF.
Похожая история с RAW. Большинство «сырых» форматов фотоаппаратов — это TIFF с дополнительными тегами, а CR3 от Canon — родственник HEIC. Проверишь TIFF первым — получишь крошечную миниатюру вместо снимка. С DICOM то же самое, только наоборот: у медицинского снимка первые 128 байт — свободная преамбула, и иногда туда кладут заголовок TIFF, чтобы файл открывался обычными просмотрщиками картинок. Поэтому DICM на смещении 128 проверяется раньше всего остального.
Бывает, что одно расширение носят два совсем разных формата. .wps — это и документ Kingsoft WPS Office, популярного в Азии офисного пакета, и файл Microsoft Works из девяностых. Kingsoft внутри хранит обычный Word 97–2003, его приложение открывает. Works — свой формат внутри того же контейнера OLE, с потоком CONTENTS, который начинается с CHNK. Его приложение честно называет по имени и говорит, что не открывает, вместо загадочного «файл повреждён».
Отдельная радость — форматы без сигнатуры. У TGA её просто нет, поэтому приходится смотреть на расширение и проверять, что заголовок хотя бы правдоподобен: тип картинки из допустимых, глубина цвета 8, 15, 16, 24 или 32 бита, ширина и высота не нулевые. У ISO-образа подпись CD001 стоит не в начале, а на смещении 0x8001. У книг Kindle тип лежит по смещению 60.
И ещё одно маленькое правило: если расширение говорит одно, а внутри другое, приложение не спорит молча, а открывает файл по содержимому и пишет над ним: «Файл назван „.pdf“, но на самом деле это архив — открыт как архив». Это куда полезнее, чем «файл повреждён».
Когда начинаешь разбирать форматы, обнаруживается неприятная правда: половина из них — ZIP. DOCX, XLSX и PPTX, EPUB, комиксы CBZ, KMZ из Google Earth, билеты Wallet, контейнеры электронной подписи ASiC-E, документы Apple Pages, Numbers и Keynote, XPS из «печати в файл» Windows, рисунки CorelDRAW начиная с X4 — всё это ZIP с разными файлами внутри.
Поэтому «это ZIP» — не ответ, а начало разговора. Дальше приложение смотрит на содержимое: pass.json в корне — билет Wallet, Index/*.iwa — документ iWork, doc.kml — карта, content/riffData.cdr — CorelDRAW, файл mimetype с application/vnd.lt.archyvai.adoc-2008 — литовский подписанный документ. Расширение тут вообще вторично: .pkpass, переименованный в .zip, всё равно откроется карточкой со штрихкодом.
RAR, 7z с паролем, ISO, CAB, DEB, а заодно и справку Windows CHM умеет распаковывать 7-Zip. Его можно собрать в WebAssembly (я взял готовую сборку 7z-wasm, внутри 7-Zip 24.09) и гонять в отдельном потоке. Звучит просто, но по дороге нашлись три сюрприза.
Первый: callMain нельзя звать дважды. После вызова модуль остаётся в каком-то своём состоянии и второй раз ведёт себя странно. Решение скучное и надёжное: на каждую команду — новый экземпляр. Чтобы это не стоило дорого, .wasm компилируется один раз, а экземпляры создаются из готового модуля.
Второй: архив не надо копировать в память. Первая мысль — положить файл в виртуальную файловую систему Emscripten. Но копия архива на пару сотен мегабайт внутри памяти WebAssembly на телефоне — плохая идея. Помогла WORKERFS: она отдаёт 7-Zip файл прямо из Blob, и тот читает его кусками, как с диска. Многотомные архивы (part1.rar, .7z.001) монтируются так же: все части — в одну папку, 7-Zip сам находит соседей.
Третий: пароль спрашивают у stdin. Если не передать -p, 7-Zip на зашифрованном архиве честно спросит пароль из стандартного ввода — и будет ждать вечно. Поэтому -p передаётся всегда, даже пустой, а пароль спрашивает уже само приложение в нормальном окне.
Бонусом — особенность самого WebView: запросы из рабочего потока он может не перехватить. Поэтому код и .wasm читает основной поток, склеивает загрузчик с кодом потока в Blob и уже из него запускает worker.
И мелочь из жизни комиксов: «сплошные» (solid) архивы 7z и RAR распаковывать по одному файлу мучительно долго — для каждого 7-Zip заново проходит всё, что лежит перед ним. Поэтому страницы комикса достаются пачками по 12.
Это мой любимый трюк. JPEG 2000 до сих пор встречается в сканах, медицинских снимках и архивах. WebView его не понимает, а отдельный декодер — это ещё пара мегабайт в APK.
Но в приложении уже есть pdf.js, а в PDF есть фильтр JPXDecode — картинка в формате JPEG 2000. И у pdf.js для него есть свой декодер. Поэтому .jp2 заворачивается в одностраничный PDF, где эта картинка — единственное содержимое, и отдаётся pdf.js. Пользователь видит фото, приложение не выросло ни на байт.
У трюка нашёлся предел, когда дошло до медицинских снимков. КТ и МРТ хранят 12–16 бит на точку, а pdf.js отдаёт картинку уже переведённой в 8 бит, по старшим битам. Для фотографии это незаметно, для томограммы — катастрофа: всё, что интересно врачу, умещается в несколько соседних значений и превращается в однотонное серое пятно.
При этом сам декодер внутри pdf.js считает точные значения — просто не отдаёт их наружу. Пришлось сделать то, чего обычно делать не стоит: на лету поправить минифицированный код воркера.
let src = await (await fetch('lib/pdfjs/pdf.worker.min.js')).text(); patch('JpxImage=class JpxImage{', 'JpxImage=globalThis.__xfJpx=class JpxImage{'); // ещё две замены: сохранить значения до перевода в 8 бит // и переименовать глобальный pdfjsWorker, чтобы pdf.js не начал рисовать PDF в основном потоке await loadScript(URL.createObjectURL(new Blob([src], { type: 'text/javascript' })));
Каждая замена проверяет, что искомый кусок нашёлся. Если очередное обновление pdf.js поменяет минифицированный текст, копия просто не соберётся, и снимок откроется прежним путём, в 8 бит, с пометкой. Молча показать неправильную картинку хуже, чем показать её грубее, но предупредить.
Раз уж дошли до томограмм. После КТ или МРТ в клинике обычно дают диск или флешку. Внутри — папка с файлами без расширения, DICOMDIR и программа для Windows, которую на телефоне не запустить. Хотелось, чтобы это открывалось на телефоне.
Разбор DICOM пришлось писать свой. Формат — длинная цепочка элементов «группа, номер, тип, длина, значение». Описать его можно за абзац, а аккуратно разобрать — нет: порядок байтов бывает прямой и обратный, тип значения записан явно или подразумевается словарём, длины бывают «неопределённые» (читай до маркера конца), последовательности вкладываются друг в друга, а сами кадры лежат в «капсуле» с таблицей смещений.
С пикселями ещё интереснее. Браузер умеет обычный JPEG, но в медицине любят JPEG без потерь (процесс 14 из стандарта T.81) и JPEG-LS — их не понимает ни один браузер. Для обоих пришлось написать свои декодеры, плюс RLE. Проверял по эталонным значениям точек из тестов pydicom: совпадение точное.
Дальше — окно яркости. Значения КТ в единицах Хаунсфилда идут примерно от −1000 (воздух) до +3000 (плотная кость, металл), а экран показывает 256 оттенков серого. Поэтому на снимок смотрят через «окно»: центр и ширину. Для лёгких одно, для костей другое, для мозга третье. В файле обычно записано окно, которое выбрал аппарат. Есть ещё «Авто» по процентилям и пресеты для КТ, а для тонкой настройки — ползунки.
Серия — это не один файл, а сотня, по срезу в каждом. Android отдаёт приложению один файл, поэтому остальные оно просит выбрать и складывает по номеру снимка и положению среза.
И главное правило этого раздела — на экране сведений прямым текстом: это просмотр, а не постановка диагноза.
Дизайнеры присылают логотипы в .eps, .ai и .cdr, а на телефоне открыть это нечем. Честный ответ — нарисовать такой файл без «родной» программы почти невозможно. EPS — это программа на языке PostScript, а не картинка, и для неё нужен интерпретатор вроде Ghostscript — с лицензией AGPL и немалым весом. CDR — закрытый формат, который до конца не разобран даже в LibreOffice.
Но почти каждая программа кладёт в файл готовую картинку для предпросмотра — чтобы проводник и программы вёрстки могли показать хоть что-то. Её я и ищу.
EPS из Photoshop — подарок: Photoshop кладёт в комментарий %ImageData: всё изображение целиком, с размерами, режимом цвета (серый, Lab, RGB, CMYK) и кодировкой (двоичная, шестнадцатеричная, ASCII85 или JPEG). Это не эскиз, а сама картинка в полном размере.
EPS с двоичным заголовком (C5 D0 D3 C6) — в начале таблица: где лежит PostScript, где эскиз TIFF, где WMF.
EPSI — эскиз прямо в комментариях PostScript, шестнадцатеричными строками.
Illustrator CS — ещё и уменьшенная картинка в метаданных XMP.
На первом же настоящем EPS из свежего Illustrator вылезла не моя ошибка. Эскиз TIFF там с палитрой и прозрачностью — редкое сочетание, и библиотека UTIF рисовала его кашей, а потом половиной картинки. Pillow такой TIFF вообще не открывает. Пришлось читать полосы точек самому.
С CorelDRAW похоже. До версии X3 файл — RIFF, а эскиз лежит в разделе DISP. Внутри — дамп буфера обмена Windows: код формата 8 (CF_DIB), а за ним BMP без 14-байтного заголовка файла. Его достаточно приклеить обратно — и браузер покажет картинку:
const bmp = new Uint8Array(14 + dib.length); bmp[0] = 0x42; bmp[1] = 0x4D; // «BM» view.setUint32(2, bmp.length, true); // размер файла view.setUint32(10, 14 + 40 + paletteBytes, true); // где начинаются точки bmp.set(dib, 14);
С X4 CDR — ZIP, а эскизы страниц лежат в metadata/thumbnails/. Заодно оттуда достаются название, автор, шрифты и даже текст рисунка.
С Illustrator повезло больше всех. С девятой версии файл .ai — это обычный PDF, к которому Illustrator дописывает свои данные для правки. Значит, его рисует тот же pdf.js, в полном качестве и со слоями. Если же при сохранении сняли галочку «Create PDF Compatible File», вместо рисунка в PDF лежит заглушка Adobe с текстом «This is an Adobe Illustrator File that was saved without PDF Content». Приложение узнаёт её и объясняет, что случилось, а не показывает страницу с непонятной надписью.
Если вы когда-нибудь получали письмо с вложением winmail.dat вместо нормальных файлов, вы знаете это чувство. Outlook иногда упаковывает вложения и оформленный текст письма в свой формат TNEF, и почти никто, кроме самого Outlook, его не открывает.
Внутри — настоящая матрёшка:
TNEF: атрибуты письма (тема, дата, кодовая страница) и свойства MAPI.
Среди свойств — вложения с именами (в Юникоде или в кодовой странице письма, угадайте, какой) и текст письма.
Текст — сжатый RTF. Алгоритм LZFu описан в документации Microsoft MS-OXRTFCP: словарь на 4 КБ со ссылками назад, заранее заполненный типовым началом RTF.
Внутри RTF часто лежит письмо в HTML. Флаг \fromhtml1 говорит, что RTF — лишь обёртка, а настоящий HTML разбросан по группам \htmltag (это уже MS-OXRTFEX).
Картинки в тексте ссылаются на вложения через cid: — их надо поставить на место и убрать из списка вложений, чтобы не было дублей.
А вложением может быть ещё одно письмо. Снова TNEF.
Определяется всё это по четырём байтам 78 9F 3E 22, и, как вы уже догадались, проверка стоит раньше PDF.
Pages, Numbers и Keynote с 2013 года хранят документ в Index/*.iwa. Это куски, сжатые Snappy, а внутри — поток сообщений protobuf. Схем Apple не публикует.
Но для просмотра схемы и не нужны. В protobuf у каждого поля есть номер и тип, поэтому сообщение можно разобрать «вслепую» и взять только то, что знаешь. Например, текст Pages — это объект типа 2001, строки в нём — поле 3, а поле 1 говорит, что это за текст: основной, колонтитул, сноска. Стили абзацев, списки, ссылки и сноски лежат в таблицах «с символа N — объект такой-то». Остальное можно спокойно пропускать.
Получается не идеальная копия документа, но вполне читаемый текст с жирным, курсивом, списками и сносками. Для «открыть на телефоне и прочитать» этого хватает.
Двести форматов — это двести способов сломаться. Поэтому для большинства форматов есть генератор тестовых файлов (Python-скрипты в tools/) и автотест в Playwright. А главное — эталон, с которым можно сравнить результат:
подписи CMS — с openssl cms -verify (RSA, RSA-PSS, ECDSA, потоковый BER, подделанный документ);
DICOM — с эталонными значениями точек из тестов pydicom;
файлы FIT с часов — с официальным декодером Garmin FIT SDK;
EXIF и PSD — с Pillow, RAW — на 13 настоящих снимках с разных камер из открытой коллекции raw.pixls.us;
векторные EMF/WMF — с отрисовкой самой Windows GDI на 41 файле;
архивы — с 7-Zip для Windows и тестовыми файлами libarchive.
Иногда ошибается не мой код, а чужой. В JPEG 2000 без потерь декодер pdf.js промахивается на единицу: на одном из тестовых снимков — в каждой четвёртой точке, судя по всему, из-за обратного вейвлет-преобразования. Для глаза разницы нет, но тест пришлось научить отличать «±1 от pdf.js» от настоящей ошибки.
В тестах libarchive, кстати, есть милая ловушка: в одном RAR с паролем у одного файла пароль другой, не как у остальных. Это не ошибка, а проверка того, что архиватор не путает пароли разных файлов. При сверке результатов такое место сначала очень похоже на баг.
Распознавание текста на компьютере тоже настоящее: тот же Tesseract, собранный в WebAssembly, с теми же моделями, что и в приложении. Поэтому тест «скан договора → текст» проверяет ровно то, что увидит пользователь.
Чтобы не было разочарований:
Только Android, версии для iPhone нет.
EPS и CorelDRAW приложение само не рисует: показывает картинку или эскиз, которые программа сохранила в файле. Если эскиза нет, так и говорит.
DICOM — для просмотра, не для диагностики. Снимки в 12-битном JPEG (Extended) пока не открываются.
Электронную подпись приложение проверяет математически («документ не изменён после подписания»), но не проверяет, можно ли доверять удостоверяющему центру. Для юридически значимой проверки есть DigiDoc, Госуслуги и подобные сервисы.
Книги с DRM не открываются и не будут: снимать защиту нельзя ни по закону, ни по правилам Google Play.
Многотомный архив Android целиком не отдаёт: приложение получает один файл, а остальные части просит выбрать вручную.
64 МБ — это много. Я знаю.
Если хочется посмотреть, как это ведёт себя на живых файлах, XanderFiles лежит в Google Play: https://play.google.com/store/apps/details?id=com.xander.files
Но гораздо интереснее другое. Напишите в комментариях формат, который у вас не открывается ничем, — желательно с примером, где такой файл встречается. Билеты Wallet, например, появились в приложении после одного-единственного посадочного талона, который телефон отказался открыть.