225 lines
8.0 KiB
PHP
225 lines
8.0 KiB
PHP
<?php
|
||
|
||
namespace App\Support;
|
||
|
||
use RuntimeException;
|
||
use ZipArchive;
|
||
|
||
/**
|
||
* 低内存流式写出 XLSX 工作簿。
|
||
*
|
||
* 退货取件管理等导出允许到数万行,PhpSpreadsheet 的常驻单元格写法在
|
||
* 512M 内存限制下会失败。这里按最小 OOXML 结构把行顺序写入临时文件,
|
||
* 最后打包成 zip 输出:内存占用与行数无关。
|
||
*
|
||
* 字符串一律写成文本单元格,长订单号/交易单号在 Excel 中不会变成
|
||
* 科学计数法,也不需要 ' 前缀;int/float 写成数值单元格。
|
||
*/
|
||
class StreamingXlsxWriter
|
||
{
|
||
private const MAX_ROWS = 1048576;
|
||
|
||
/** @var resource */
|
||
private $sheet;
|
||
|
||
private string $sheetPath = '';
|
||
|
||
private int $row = 0;
|
||
|
||
public function __construct(private readonly string $sheetName = 'Sheet1')
|
||
{
|
||
$path = tempnam(sys_get_temp_dir(), 'xlsx_sheet_');
|
||
|
||
if ($path === false) {
|
||
throw new RuntimeException('无法创建 XLSX 临时文件。');
|
||
}
|
||
|
||
$handle = fopen($path, 'wb');
|
||
|
||
if ($handle === false) {
|
||
@unlink($path);
|
||
|
||
throw new RuntimeException('无法写入 XLSX 临时文件。');
|
||
}
|
||
|
||
$this->sheetPath = $path;
|
||
$this->sheet = $handle;
|
||
|
||
fwrite($handle, '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||
. '<sheetData>');
|
||
}
|
||
|
||
/**
|
||
* @param list<string|int|float|null> $cells
|
||
*/
|
||
public function appendRow(array $cells): void
|
||
{
|
||
if (++$this->row > self::MAX_ROWS) {
|
||
throw new RuntimeException('导出行数超过 XLSX 单表上限。');
|
||
}
|
||
|
||
$xml = '<row r="' . $this->row . '">';
|
||
|
||
foreach (array_values($cells) as $index => $value) {
|
||
if ($value === null || $value === '') {
|
||
continue;
|
||
}
|
||
|
||
$reference = $this->columnName($index + 1) . $this->row;
|
||
$xml .= is_int($value) || is_float($value)
|
||
? '<c r="' . $reference . '"><v>' . $this->number($value) . '</v></c>'
|
||
: '<c r="' . $reference . '" t="inlineStr"><is><t xml:space="preserve">'
|
||
. $this->escape((string) $value) . '</t></is></c>';
|
||
}
|
||
|
||
fwrite($this->sheet, $xml . '</row>');
|
||
}
|
||
|
||
/**
|
||
* 收尾并写入给定输出流(例如 php://output)。
|
||
*
|
||
* @param resource $output
|
||
*/
|
||
public function writeTo($output): void
|
||
{
|
||
fwrite($this->sheet, '</sheetData></worksheet>');
|
||
fclose($this->sheet);
|
||
|
||
$zipPath = tempnam(sys_get_temp_dir(), 'xlsx_zip_');
|
||
|
||
if ($zipPath === false) {
|
||
$this->cleanup();
|
||
|
||
throw new RuntimeException('无法创建 XLSX 打包文件。');
|
||
}
|
||
|
||
try {
|
||
$zip = new ZipArchive();
|
||
|
||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||
throw new RuntimeException('无法创建 XLSX 压缩包。');
|
||
}
|
||
|
||
$zip->addFromString('[Content_Types].xml', $this->contentTypes());
|
||
$zip->addFromString('_rels/.rels', $this->rootRelationships());
|
||
$zip->addFromString('xl/workbook.xml', $this->workbook());
|
||
$zip->addFromString('xl/_rels/workbook.xml.rels', $this->workbookRelationships());
|
||
$zip->addFromString('xl/styles.xml', $this->styles());
|
||
$zip->addFile($this->sheetPath, 'xl/worksheets/sheet1.xml');
|
||
$zip->close();
|
||
|
||
$handle = fopen($zipPath, 'rb');
|
||
|
||
if ($handle === false) {
|
||
throw new RuntimeException('无法读取 XLSX 压缩包。');
|
||
}
|
||
|
||
stream_copy_to_stream($handle, $output);
|
||
fclose($handle);
|
||
} finally {
|
||
@unlink($zipPath);
|
||
$this->cleanup();
|
||
}
|
||
}
|
||
|
||
private function cleanup(): void
|
||
{
|
||
if ($this->sheetPath !== '' && is_file($this->sheetPath)) {
|
||
@unlink($this->sheetPath);
|
||
}
|
||
}
|
||
|
||
private function number(int|float $value): string
|
||
{
|
||
if (is_int($value)) {
|
||
return (string) $value;
|
||
}
|
||
|
||
if (! is_finite($value)) {
|
||
return '0';
|
||
}
|
||
|
||
$text = rtrim(rtrim(sprintf('%.10F', $value), '0'), '.');
|
||
|
||
return ($text === '' || $text === '-') ? '0' : $text;
|
||
}
|
||
|
||
private function escape(string $value): string
|
||
{
|
||
if (! mb_check_encoding($value, 'UTF-8')) {
|
||
$value = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
|
||
}
|
||
|
||
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/u', '', $value) ?? $value;
|
||
|
||
return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||
}
|
||
|
||
private function columnName(int $index): string
|
||
{
|
||
$name = '';
|
||
|
||
while ($index > 0) {
|
||
$index--;
|
||
$name = chr(65 + ($index % 26)) . $name;
|
||
$index = intdiv($index, 26);
|
||
}
|
||
|
||
return $name;
|
||
}
|
||
|
||
private function contentTypes(): string
|
||
{
|
||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||
. '<Default Extension="xml" ContentType="application/xml"/>'
|
||
. '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||
. '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||
. '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
|
||
. '</Types>';
|
||
}
|
||
|
||
private function rootRelationships(): string
|
||
{
|
||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||
. '</Relationships>';
|
||
}
|
||
|
||
private function workbook(): string
|
||
{
|
||
$name = htmlspecialchars($this->sheetName, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||
|
||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
. '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||
. 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||
. '<sheets><sheet name="' . $name . '" sheetId="1" r:id="rId1"/></sheets>'
|
||
. '</workbook>';
|
||
}
|
||
|
||
private function workbookRelationships(): string
|
||
{
|
||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
||
. '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
|
||
. '</Relationships>';
|
||
}
|
||
|
||
private function styles(): string
|
||
{
|
||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||
. '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||
. '<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
|
||
. '<fills count="2"><fill><patternFill patternType="none"/></fill>'
|
||
. '<fill><patternFill patternType="gray125"/></fill></fills>'
|
||
. '<borders count="1"><border/></borders>'
|
||
. '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
||
. '<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>'
|
||
. '</styleSheet>';
|
||
}
|
||
}
|