CaiWuXiTong/app/Support/StreamingXlsxWriter.php

225 lines
8.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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>';
}
}