Что нового в FastExcelReader 4: работает с XLS, свои имена колонок и чтение XLSX в полтора раза быстрее
avadim/fast-excel-reader — библиотека для PHP, которая читает таблицы Excel экономно по памяти.
Она не грузит файл целиком в память, а идёт по нему потоком, строка за строкой. Благодаря этому
файл на сто тысяч строк читается в считанные десятки мегабайт, а не в гигабайты.
В версии 4.0.0 появилось три заметные вещи:
- Чтение старого формата XLS (Excel 97-2003) — тем же кодом, что и XLSX.
withHeader()теперь умеет принимать свои имена колонок.- Чтение XLSX стало примерно в полтора раза быстрее — без единой правки в вашем коде.
Плюс несколько исправленных багов и одно изменение, из-за которого версия и стала мажорной. Разберём всё по порядку.
1. Чтение старых XLS
Проблема
Формату XLS больше четверти века, но он никуда не делся. Выгрузки из 1С, банковские выписки,
отчёты из старых учётных систем — всё это до сих пор часто приходит в .xls. А XLS и XLSX —
это два совершенно разных формата. XLSX внутри обычный ZIP-архив с XML-файлами, а XLS —
бинарный контейнер OLE2, тот же, что у старых .doc.
Раньше на это приходилось ставить вторую библиотеку и писать развилку.
Как теперь
Никакой развилки не нужно. Excel::open() сам смотрит на файл и выбирает нужный ридер:
use avadim\FastExcelReader\Excel;
// XLSX
$excel = Excel::open(__DIR__ . '/report.xlsx');
$rows = $excel->readRows();
// XLS — тот же самый вызов
$excel = Excel::open(__DIR__ . '/report.xls');
$rows = $excel->readRows();
Весь остальной API работает одинаково для обоих форматов. Вот совершенно один и тот же код,
запущенный на demo-04-styles.xlsx и на demo-04-styles.xls:
$excel = Excel::open($file);
echo json_encode($excel->getSheetNames());
// {"1":"Demo"} — и для xlsx, и для xls
$cells = $excel->sheet()->setReadArea('A1:B2')->readCellsWithStyles('fill-color');
echo json_encode($cells['A1']['s']);
// {"fill-color":"#9FC63C"} — и для xlsx, и для xls
Области чтения, режимы ключей, withHeader(), генератор строк, форматирование дат, стили,
изображения — всё это написано один раз и работает для обоих форматов.
Важная деталь: расширение файла не смотрится вообще
Excel::open() определяет формат по первым байтам файла, а не по тому, что написано после точки.
Это не педантизм, а решение реальной проблемы: .xls — самое популярное расширение для файлов,
которые на самом деле являются XLSX, HTML или CSV. Такие выгрузки регулярно делают старые
веб-приложения, и именно на них ломались наивные проверки pathinfo($file, PATHINFO_EXTENSION).
Если вам нужно наоборот — строго убедиться, что перед вами настоящий XLS, — есть два метода:
// вернёт true/false, ничего не открывая
if (Excel::isXls($file)) {
echo "Это настоящий XLS\n";
}
// откроет только XLS, на всём остальном бросит исключение
$excel = Excel::openXls($file);
Проверим на реальных файлах:
var_dump(Excel::isXls('demo-01-base.xlsx')); // bool(false)
var_dump(Excel::isXls('continue-sst.xls')); // bool(true)
try {
Excel::openXls('demo-01-base.xlsx');
}
catch (\avadim\FastExcelReader\Exception $e) {
echo $e->getMessage();
// Not an OLE2 compound file: ".../demo-01-base.xlsx"
}
Что поддерживается
- Несколько листов, включая скрытые и «очень скрытые»
- Все типы ячеек: текст, числа, логические значения, ошибки, пустые ячейки
- Автоопределение дат по числовым форматам и тот же API форматирования, что у XLSX
- Стили: шрифты, заливки, рамки, выравнивание, числовые форматы, цвета палитры
- Объединённые ячейки, размеры листа, ширины колонок, высоты строк
- Текст формул, включая общие (shared) формулы
- Встроенные изображения
Чего ждать не стоит
Честный список ограничений — лучше узнать о них сейчас, чем в проде:
- Только BIFF8. Файлы из Excel 5.0/95 (BIFF5/BIFF7) отклоняются с внятным сообщением. Их нужно пересохранить как «Excel 97-2003» или как XLSX.
- Зашифрованные книги не поддерживаются — библиотека честно откажется, а не вернёт мусор.
- Диаграммы и макро-листы пропускаются — данных ячеек в них всё равно нет.
- Текст формул восстанавливается не всегда. Формулы с трёхмерными ссылками на другие листы,
именованными диапазонами, массивами и вызовами надстроек вернут
nullвместо текста. Важно: закэшированный результат такой формулы доступен всегда, то есть на чтение данных это никак не влияет. - Сам формат ограничен 65 536 строками и 256 колонками на лист.
Мелочь, о которой стоит помнить
Это особенность форматов, а не библиотеки, но она видна в результатах.
Номера числовых форматов различаются. Ячейка с форматом General может иметь встроенный
format-num-id = 0 в XLSX и произвольный 164 в XLS, потому что нумерацию задаёт программа,
записавшая файл. Сам шаблон формата и тип значения при этом совпадают.
А вот текст формул теперь одинаковый для обоих форматов — с ведущим =:
// formulas.xlsx и formulas.xls дают один и тот же результат
{"B2":"=A2+1","C2":"=B2+1","D2":"=C2+1"}
Раньше XLS отдавал формулу без = (A2+1), но с версии 4.0.1 это выровнено: если формула есть,
её текст всегда начинается с =, независимо от формата. Формулы, текст которых восстановить не
удалось, по-прежнему возвращают null.
2. withHeader() со своими именами колонок
Как было
Метод withHeader() берёт первую строку листа как заголовок и превращает остальные строки в
ассоциативные массивы:
$excel = Excel::open('demo-01-base.xlsx');
$rows = $excel->sheet()->withHeader()->readRows();
[
2 => ['Item' => 'Vermicelli', 'Category' => 'Pasta', 'City' => 'Tokyo', 'Quantity' => 1400, ...],
3 => ['Item' => 'Eggplant', 'Category' => 'Vegetables', 'City' => 'Moscow', 'Quantity' => 2076, ...],
]
Удобно — пока заголовки в файле аккуратные. А в жизни они бывают на русском, с пробелами, с
переносами строк, с опечатками, а иногда меняются от выгрузки к выгрузке. И ваш код превращается
в $row['Кол-во, шт. '] с пробелом на конце, который однажды пропадёт и всё сломает.
Как стало
Теперь withHeader() принимает список имён. Строка заголовка по-прежнему пропускается, но
ключи берутся из вашего списка:
$excel = Excel::open('demo-01-base.xlsx');
$rows = $excel->sheet()
->setReadArea('B2:D6')
->withHeader(['category', 'contractor', 'date'])
->readRows();
[
3 => ['category' => 'Vegetables', 'contractor' => 'Foody Stock', 'date' => 1286928000],
4 => ['category' => 'Pasta', 'contractor' => 'Horns & Hooves Company', 'date' => 1327795200],
5 => ['category' => 'Vegetables', 'contractor' => 'Overseas Company', 'date' => 1319155200],
6 => ['category' => 'Fruits', 'contractor' => 'Overseas Company', 'date' => 1325808000],
]
Три вещи, которые тут важны:
Имена позиционные. Первое имя достаётся первой колонке области чтения, второе — второй, и так
далее. Буквы колонок знать не нужно. Поэтому один и тот же вызов работает и для листа, где данные
начинаются с A1, и для листа, где они начинаются с B2 — как в примере выше.
Список может быть короче, чем колонок. Непокрытые колонки сохранят имена из строки заголовка:
$rows = $excel->sheet()
->setReadArea('A1:C3')
->withHeader(['product', 'kind']) // а третью колонку не переименовываем
->readRows();
[
2 => ['product' => 'Vermicelli', 'kind' => 'Pasta', 'Contractor' => 'Food Paradise Corp.'],
3 => ['product' => 'Eggplant', 'kind' => 'Vegetables', 'Contractor' => 'Foody Stock'],
]
Вызов без аргументов работает как раньше — обратная совместимость не нарушена.
Работает для XLSX, XLS и CSV. Имя выбрано под стать writeHeader() из родственной
библиотеки fast-excel-writer.
3. Чтение XLSX стало быстрее примерно в полтора раза
Что было не так
Внутри чтение листа устроено так: библиотека идёт по XML потоковым парсером XMLReader и на
каждой ячейке <c> вызывала XMLReader::expand().
Звучит безобидно, но expand() — это не «взять ссылку на текущий узел». Это полное копирование
узла в новый DOMDocument плюс создание PHP-объекта DOMElement поверх копии. На листе в миллион
ячеек — миллион пар «выделить и освободить память» ради того, чтобы прочитать пару атрибутов и
текст одного вложенного тега.
Что стало
Теперь значение ячейки собирается тем же обходом read(), без создания DOM-узла. Логика
приведения значений к типам не изменилась ни на строчку — её просто отделили от разбора XML.
Замеры на стандартных наборах (PHP 8.4, Xdebug и OPcache выключены, лучшее из пяти прогонов, «до» и «после» чередовались):
| Файл | Было | Стало | Ускорение |
|---|---|---|---|
| 1 000 строк | 63,6 мс | 42,4 мс | 1,50× |
| 20 000 строк (296 тыс. ячеек) | 1 593 мс | 1 035 мс | 1,54× |
| 100 000 строк (980 тыс. ячеек) | 5 278 мс | 3 471 мс | 1,52× |
| 40 000 строк с датами | 1 781 мс | 1 129 мс | 1,58× |
| 40 000 строк, общие строки | 2 037 мс | 1 464 мс | 1,39× |
| 40 000 строк, инлайн-строки | 1 674 мс | 1 066 мс | 1,57× |
| 2 000 строк × 150 колонок | 1 645 мс | 1 129 мс | 1,46× |
Разброс — от 1,39× до 1,58×, в среднем около полутора раз. Там, где ускорение меньше, основное время уходит не на разбор XML, а на другую работу — например, на словарь общих строк.
Пик памяти не изменился вообще — совпадает до третьего знака после запятой на всех наборах.
Это ожидаемо: expand() ничего не накапливал (копия жила до следующей итерации), он просто зря
тратил процессорное время.
Менять в своём коде ничего не нужно. Значения, типы и порядок строк остались прежними — это проверено побайтовым сравнением результатов на семи наборах данных в одиннадцати режимах чтения: ноль расхождений.
Напоминание про память
Раз уж речь зашла о производительности — главный приём библиотеки никуда не делся. Если файл
большой, читайте его генератором nextRow(), а не методом readRows():
$excel = Excel::open('huge.xlsx');
// так весь результат окажется в памяти
$rows = $excel->sheet()->readRows();
// а так в памяти одновременно живёт одна строка
foreach ($excel->sheet()->withHeader()->nextRow() as $rowNum => $row) {
echo "$rowNum: {$row['Item']} / {$row['City']}\n";
}
// 2: Vermicelli / Tokyo
// 3: Eggplant / Moscow
// 4: Spaghetti / Tokyo
4. Исправленные ошибки
Падение на файлах с отформатированным styles.xml
Самый неприятный из багов. Чтение полных стилей — getCompleteStyleByIdx(),
readCellsWithStyles() и всё, что на них построено, — падало с фатальной ошибкой:
Error: Call to undefined method DOMText::getAttribute()
Причина: разбор стилей шёл по всем дочерним узлам тегов <fonts>, <fills>, <borders> и
<cellXfs>. Если файл записан с отступами — а так делают многие генераторы XLSX, — то между
тегами лежат текстовые узлы с переносами строк, и библиотека пыталась спросить у пробелов
атрибуты.
У той же ошибки была и вторая, тихая половина: текстовые узлы попадали бы в индексируемые
таблицы стилей и сдвигали позиции, на которые ссылаются fontId, fillId, borderId и xfId.
То есть даже там, где не было падения, стили могли поехать. Теперь учитываются только элементы.
readCellsWithStylesFrom() возвращал значения без стилей
Метод вызывал внутри readCells() вместо readCellsWithStyles(), так что стили просто терялись.
Теперь возвращает то, что обещает:
$cells = $excel->sheet()->readCellsWithStylesFrom('A1:A2');
echo json_encode(array_keys($cells['A1']));
// ["v","s","f","t","o"] — v это значение, s это стиль
readCellsWithStyles($styleKey) не сужал результат
Пример из документации самого метода — 'fill-color' — никогда не работал: ключ искали не на том
уровне вложенности, и вместо одного свойства возвращался весь стиль целиком. Теперь работает:
$cells = $excel->sheet()->setReadArea('A1:B1')->readCellsWithStyles('fill-color');
echo json_encode($cells['A1']['s']); // {"fill-color":"#9FC63C"}
echo json_encode($cells['B1']['s']); // {"fill-color":"#3C636F"}
Одна тонкость: если у ячейки запрошенного свойства нет, вернётся полный стиль, а не null.
Сделано намеренно — чтобы опечатка в имени ключа не приводила к молчаливой потере данных.
Имя группы тоже работает и вернёт группу целиком:
$cells = $excel->sheet()->readCellsWithStyles('font');
// ['font' => ['font-size' => '10', 'font-name' => 'Arial', ...]]
5. Что может сломаться при обновлении
Версия мажорная, и вот единственная причина этого.
Внутри появились базовые классы AbstractBook и AbstractSheet — именно они позволили XLSX и
XLS делить одну реализацию API. Из-за этого методы, которые раньше возвращали конкретный Sheet,
теперь объявлены как возвращающие AbstractSheet, а fluent-сеттеры книги — AbstractBook:
Excel::open(): AbstractBook // было Excel
Excel::sheet(): ?AbstractSheet // было ?Sheet
Сами объекты не изменились. При открытии XLSX вы по-прежнему получаете Excel и Sheet:
$excel = Excel::open('demo-01-base.xlsx');
echo get_class($excel); // avadim\FastExcelReader\Excel
echo get_class($excel->sheet()); // avadim\FastExcelReader\Sheet
Сломаться может только код с явными объявлениями типов. Вот так — упадёт:
use avadim\FastExcelReader\Sheet;
function processSheet(Sheet $sheet): void { /* ... */ } // ← слишком узко
processSheet(Excel::open('report.xls')->sheet());
// TypeError: processSheet(): Argument #1 ($sheet) must be of type
// avadim\FastExcelReader\Sheet, avadim\FastExcelReader\Xls\XlsSheet given
Обратите внимание: на XLSX такой код продолжит работать, а упадёт только на XLS. Это самый
коварный вариант — ошибка вылезет не при обновлении, а когда кто-то впервые загрузит .xls.
Чинится заменой типа на базовый:
use avadim\FastExcelReader\AbstractSheet;
function processSheet(AbstractSheet $sheet): void { /* ... */ } // работает для xlsx и xls
Если типов в сигнатурах нет — а в большинстве кода их нет, — обновление пройдёт незаметно.
Как обновиться
composer require avadim/fast-excel-reader:^4.0
Требования не изменились: PHP 7.4 и выше, расширения zip, mbstring, ctype, xmlreader.
Коротко
Excel::open()теперь открывает и XLSX, и XLS — формат определяется по содержимому файла, а не по расширению. Весь остальной код не меняется.withHeader(['имя', 'другое_имя'])задаёт имена колонок самостоятельно, не завися от того, что написано в шапке файла.- Чтение XLSX ускорилось примерно в полтора раза при тех же результатах и той же памяти.
- Починены падение на файлах с отформатированным
styles.xmlи два метода чтения стилей. - Если в вашем коде есть
SheetилиExcelв объявлениях типов — замените наAbstractSheetиAbstractBook.
Ссылки: репозиторий · релиз 4.0.0 · документация по XLS