Files
JobcardSystem/app/Domain/Reporting/CsvExporter.php
T

80 lines
2.2 KiB
PHP

<?php
declare(strict_types=1);
/**
* Serializes tabular data as RFC 4180-compatible CSV.
*/
final class CsvExporter
{
private const UTF8_BOM = "\xEF\xBB\xBF";
/**
* @param list<mixed> $headers
* @param list<list<mixed>> $rows
*/
public function export(array $headers, array $rows, bool $withBom = false): string
{
$records = [];
$records[] = $this->formatRecord($headers);
$headerCount = count($headers);
$rowNumber = 0;
foreach ($rows as $row) {
$rowNumber++;
if (!is_array($row)) {
throw new InvalidArgumentException(sprintf('CSV row %d must be an array.', $rowNumber));
}
$fieldCount = count($row);
if ($fieldCount !== $headerCount) {
throw new InvalidArgumentException(sprintf(
'CSV row %d has %d fields; expected %d fields for %d headers.',
$rowNumber,
$fieldCount,
$headerCount,
$headerCount,
));
}
$records[] = $this->formatRecord($row);
}
$csv = implode("\r\n", $records) . "\r\n";
return $withBom ? self::UTF8_BOM . $csv : $csv;
}
/**
* @param list<mixed> $fields
*/
private function formatRecord(array $fields): string
{
return implode(',', array_map(
fn (mixed $field): string => $this->escapeField($field),
$fields,
));
}
private function escapeField(mixed $field): string
{
if ($field === null) {
$value = '';
} elseif (is_scalar($field)) {
$value = (string)$field;
} else {
throw new InvalidArgumentException('CSV fields must be scalar values or null.');
}
// Prefix formula-like values so spreadsheet programs treat them as text.
if ($value !== '' && preg_match('/^[=+\-@]/', $value) === 1) {
$value = "'" . $value;
}
if (strpbrk($value, ",\"\r\n") === false) {
return $value;
}
return '"' . str_replace('"', '""', $value) . '"';
}
}