340 lines
12 KiB
PHP
340 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Business;
|
||
|
||
use Generator;
|
||
use RuntimeException;
|
||
use ZipArchive;
|
||
|
||
/**
|
||
* xlsx 工作表流式读取器(按表头取列)。
|
||
*
|
||
* 主要给几十万行的大表用(聚水潭导出订单、快递运费明细等),直接套常规 Excel 读法会踩坑:
|
||
* - 字符串用 inlineStr 内联存储(sharedStrings 里是空的);
|
||
* - worksheet 的 <dimension> 只写一个 A1,靠它判断行数会把整表读成空;
|
||
* - 一行订单一个商品明细,单文件常见上万行 × 上百列,解压后几十 MB。
|
||
*
|
||
* 所以这里用 ZipArchive 取压缩流 + expat 增量解析,只保留调用方点名的列,
|
||
* 内存占用与文件大小无关(导出 20 万行也不会整表进内存)。
|
||
*/
|
||
class XlsxSheetReader
|
||
{
|
||
private const READ_CHUNK = 262144;
|
||
|
||
/**
|
||
* 逐行读取导出文件(跳过表头行)。
|
||
*
|
||
* @param list<string|list<string>> $columns 需要取出的列(缺列会报错)。给字符串就是精确匹配表头;
|
||
* 给数组表示同一列有多个可选表头(取第一个命中的,
|
||
* 返回键用数组里的第一个名字)。
|
||
* @param list<string|list<string>> $optionalColumns 可选列:文件里有就取,没有就跳过(不报错)。
|
||
* @return Generator<int, array<string, string>>
|
||
*/
|
||
public function rows(string $path, array $columns, array $optionalColumns = []): Generator
|
||
{
|
||
if (! is_file($path)) {
|
||
throw new RuntimeException('订单导出文件不存在:' . $path);
|
||
}
|
||
|
||
$zip = new ZipArchive();
|
||
if ($zip->open($path) !== true) {
|
||
throw new RuntimeException('无法打开订单导出文件(不是有效的 xlsx):' . $path);
|
||
}
|
||
|
||
$stream = $zip->getStream($this->worksheetPath($zip));
|
||
if ($stream === false) {
|
||
$zip->close();
|
||
|
||
throw new RuntimeException('订单导出文件里没有可读的工作表。');
|
||
}
|
||
|
||
try {
|
||
yield from $this->parseSheet($stream, $this->sharedStrings($zip), $columns, $optionalColumns);
|
||
} finally {
|
||
fclose($stream);
|
||
$zip->close();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param resource $stream
|
||
* @param list<string> $shared
|
||
* @param list<string|list<string>> $columns
|
||
* @param list<string|list<string>> $optionalColumns
|
||
* @return Generator<int, array<string, string>>
|
||
*/
|
||
private function parseSheet($stream, array $shared, array $columns, array $optionalColumns): Generator
|
||
{
|
||
$state = [
|
||
'row' => [],
|
||
'column' => -1,
|
||
'type' => null,
|
||
'value' => '',
|
||
'inValue' => false,
|
||
'rowNumber' => 0,
|
||
'wanted' => [],
|
||
'pending' => [],
|
||
];
|
||
|
||
$parser = xml_parser_create('UTF-8');
|
||
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
|
||
|
||
xml_set_element_handler(
|
||
$parser,
|
||
static function ($parser, string $name, array $attributes) use (&$state): void {
|
||
$name = self::localName($name);
|
||
|
||
switch ($name) {
|
||
case 'row':
|
||
$state['row'] = [];
|
||
$state['column'] = -1;
|
||
break;
|
||
|
||
case 'c':
|
||
$state['column'] = self::columnIndex($attributes['r'] ?? null, $state['column'] + 1);
|
||
$state['type'] = $attributes['t'] ?? null;
|
||
$state['value'] = '';
|
||
break;
|
||
|
||
case 'v':
|
||
case 't':
|
||
$state['inValue'] = true;
|
||
break;
|
||
}
|
||
},
|
||
static function ($parser, string $name) use (&$state, $shared, $columns, $optionalColumns): void {
|
||
$name = self::localName($name);
|
||
|
||
switch ($name) {
|
||
case 'v':
|
||
case 't':
|
||
$state['inValue'] = false;
|
||
break;
|
||
|
||
case 'c':
|
||
$state['row'][$state['column']] = self::cellValue($state['type'], $state['value'], $shared);
|
||
break;
|
||
|
||
case 'row':
|
||
$state['rowNumber']++;
|
||
if ($state['rowNumber'] === 1) {
|
||
$state['wanted'] = self::wantedColumns($state['row'], $columns, $optionalColumns);
|
||
|
||
break;
|
||
}
|
||
|
||
$values = [];
|
||
foreach ($state['wanted'] as $index => $column) {
|
||
$values[$column] = $state['row'][$index] ?? '';
|
||
}
|
||
$state['pending'][] = $values;
|
||
break;
|
||
}
|
||
}
|
||
);
|
||
|
||
xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$state): void {
|
||
if ($state['inValue']) {
|
||
$state['value'] .= $data;
|
||
}
|
||
});
|
||
|
||
$first = true;
|
||
try {
|
||
while (! feof($stream)) {
|
||
$chunk = fread($stream, self::READ_CHUNK);
|
||
if ($chunk === false || $chunk === '') {
|
||
break;
|
||
}
|
||
if ($first) {
|
||
// 聚水潭导出的 XML 带 UTF-8 BOM,expat 会当成非法前缀。
|
||
$chunk = preg_replace('/^\xEF\xBB\xBF/', '', $chunk, 1);
|
||
$first = false;
|
||
}
|
||
|
||
if (xml_parse($parser, $chunk, false) === 0) {
|
||
throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser)));
|
||
}
|
||
|
||
while ($state['pending'] !== []) {
|
||
yield array_shift($state['pending']);
|
||
}
|
||
}
|
||
|
||
if (xml_parse($parser, '', true) === 0) {
|
||
throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser)));
|
||
}
|
||
} finally {
|
||
xml_parser_free($parser);
|
||
}
|
||
|
||
while ($state['pending'] !== []) {
|
||
yield array_shift($state['pending']);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 表头行 -> [列下标 => 列名],并校验需要的列都在;可选列缺了不算错。
|
||
*
|
||
* @param array<int, string> $header
|
||
* @param list<string|list<string>> $columns
|
||
* @param list<string|list<string>> $optionalColumns
|
||
* @return array<int, string>
|
||
*/
|
||
private static function wantedColumns(array $header, array $columns, array $optionalColumns = []): array
|
||
{
|
||
$byName = [];
|
||
foreach ($header as $index => $name) {
|
||
$name = trim($name);
|
||
if ($name !== '' && ! isset($byName[$name])) {
|
||
$byName[$name] = $index;
|
||
}
|
||
}
|
||
|
||
$wanted = [];
|
||
foreach ($columns as $column) {
|
||
$names = is_array($column) ? array_values($column) : [$column];
|
||
$matched = null;
|
||
foreach ($names as $name) {
|
||
if (isset($byName[$name])) {
|
||
$matched = $name;
|
||
break;
|
||
}
|
||
}
|
||
if ($matched === null) {
|
||
throw new RuntimeException('文件缺少列:' . implode(' / ', $names));
|
||
}
|
||
$wanted[$byName[$matched]] = $names[0];
|
||
}
|
||
|
||
foreach ($optionalColumns as $column) {
|
||
$names = is_array($column) ? array_values($column) : [$column];
|
||
$matched = null;
|
||
foreach ($names as $name) {
|
||
if (isset($byName[$name])) {
|
||
$matched = $name;
|
||
break;
|
||
}
|
||
}
|
||
if ($matched !== null && ! isset($wanted[$byName[$matched]])) {
|
||
$wanted[$byName[$matched]] = $names[0];
|
||
}
|
||
}
|
||
|
||
return $wanted;
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $shared
|
||
*/
|
||
private static function cellValue(?string $type, string $value, array $shared): string
|
||
{
|
||
if ($type === 's') {
|
||
return $shared[(int) $value] ?? '';
|
||
}
|
||
|
||
return trim($value);
|
||
}
|
||
|
||
/** 单元格引用(如 AV12)-> 0 基列下标。 */
|
||
private static function columnIndex(?string $reference, int $fallback): int
|
||
{
|
||
if ($reference === null || preg_match('/^([A-Z]+)/i', $reference, $matches) !== 1) {
|
||
return $fallback;
|
||
}
|
||
|
||
$letters = strtoupper($matches[1]);
|
||
$index = 0;
|
||
for ($i = 0, $length = strlen($letters); $i < $length; $i++) {
|
||
$index = $index * 26 + (ord($letters[$i]) - 64);
|
||
}
|
||
|
||
return $index - 1;
|
||
}
|
||
|
||
private function worksheetPath(ZipArchive $zip): string
|
||
{
|
||
$workbook = $zip->getFromName('xl/workbook.xml');
|
||
$relationships = $zip->getFromName('xl/_rels/workbook.xml.rels');
|
||
if (is_string($workbook) && is_string($relationships)
|
||
&& preg_match('/<sheet[^>]*r:id="([^"]+)"/', $workbook, $sheet) === 1
|
||
&& preg_match('/Id="' . preg_quote($sheet[1], '/') . '"[^>]*Target="([^"]+)"/', $relationships, $target) === 1) {
|
||
$path = ltrim($target[1], '/');
|
||
|
||
return str_starts_with($path, 'xl/') ? $path : 'xl/' . $path;
|
||
}
|
||
|
||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||
$name = (string) $zip->getNameIndex($i);
|
||
if (preg_match('#^xl/worksheets/[^/]+\.xml$#', $name) === 1) {
|
||
return $name;
|
||
}
|
||
}
|
||
|
||
return 'xl/worksheets/sheet1.xml';
|
||
}
|
||
|
||
/**
|
||
* 读取共享字符串表;聚水潭导出通常为空,但标准 xlsx 会用到。
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private function sharedStrings(ZipArchive $zip): array
|
||
{
|
||
$xml = $zip->getFromName('xl/sharedStrings.xml');
|
||
if (! is_string($xml) || $xml === '') {
|
||
return [];
|
||
}
|
||
|
||
$out = [];
|
||
$index = -1;
|
||
$buffer = '';
|
||
$inside = false;
|
||
|
||
$parser = xml_parser_create('UTF-8');
|
||
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
|
||
xml_set_element_handler(
|
||
$parser,
|
||
static function ($parser, string $name) use (&$inside, &$buffer, &$index): void {
|
||
$name = self::localName($name);
|
||
|
||
if ($name === 'si') {
|
||
$index++;
|
||
$buffer = '';
|
||
} elseif ($name === 't') {
|
||
$inside = true;
|
||
}
|
||
},
|
||
static function ($parser, string $name) use (&$inside, &$buffer, &$out): void {
|
||
$name = self::localName($name);
|
||
|
||
if ($name === 't') {
|
||
$inside = false;
|
||
} elseif ($name === 'si') {
|
||
$out[] = $buffer;
|
||
}
|
||
}
|
||
);
|
||
xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$inside, &$buffer): void {
|
||
if ($inside) {
|
||
$buffer .= $data;
|
||
}
|
||
});
|
||
|
||
try {
|
||
xml_parse($parser, preg_replace('/^\xEF\xBB\xBF/', '', $xml, 1), true);
|
||
} finally {
|
||
xml_parser_free($parser);
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
private static function localName(string $name): string
|
||
{
|
||
$separator = strrpos($name, ':');
|
||
|
||
return $separator === false ? $name : substr($name, $separator + 1);
|
||
}
|
||
}
|