|
|
<?php
|
|
|
|
|
|
namespace App\Support;
|
|
|
|
|
|
use App\Models\DictItem;
|
|
|
use App\Models\StudyTour;
|
|
|
use App\Models\Venue;
|
|
|
use PhpOffice\PhpWord\IOFactory;
|
|
|
use PhpOffice\PhpWord\PhpWord;
|
|
|
use PhpOffice\PhpWord\SimpleType\Jc;
|
|
|
use PhpOffice\PhpWord\SimpleType\TblWidth;
|
|
|
use PhpOffice\PhpWord\SimpleType\VerticalJc;
|
|
|
use ZipArchive;
|
|
|
|
|
|
class StudyTourDeclarationExporter
|
|
|
{
|
|
|
public const MAX_BATCH = 50;
|
|
|
|
|
|
private const SEASON_FALLBACK = [
|
|
|
['value' => 'spring', 'label' => '春季'],
|
|
|
['value' => 'summer', 'label' => '夏季'],
|
|
|
['value' => 'autumn', 'label' => '秋季'],
|
|
|
['value' => 'winter', 'label' => '冬季'],
|
|
|
];
|
|
|
|
|
|
private const AUDIENCE_FALLBACK = [
|
|
|
['value' => 'kindergarten', 'label' => '幼儿园'],
|
|
|
['value' => 'primary', 'label' => '小学'],
|
|
|
['value' => 'junior', 'label' => '初中'],
|
|
|
['value' => 'high', 'label' => '高中'],
|
|
|
['value' => 'all', 'label' => '全学段'],
|
|
|
];
|
|
|
|
|
|
/**
|
|
|
* @param iterable<int, StudyTour> $tours
|
|
|
* @return array{path: string, filename: string, mime: string}
|
|
|
*/
|
|
|
public static function writeDownload(iterable $tours): array
|
|
|
{
|
|
|
$list = [];
|
|
|
foreach ($tours as $tour) {
|
|
|
if ($tour instanceof StudyTour) {
|
|
|
$list[] = $tour;
|
|
|
}
|
|
|
}
|
|
|
if ($list === []) {
|
|
|
throw new \RuntimeException('未找到可导出的线路');
|
|
|
}
|
|
|
|
|
|
if (count($list) === 1) {
|
|
|
$tour = $list[0];
|
|
|
$path = self::writeDocxFile(self::payloadFromTour($tour));
|
|
|
|
|
|
return [
|
|
|
'path' => $path,
|
|
|
'filename' => self::docxFilename($tour),
|
|
|
'mime' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
|
];
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
'path' => self::writeZipFile($list),
|
|
|
'filename' => '研学线路申报表-'.date('Ymd-His').'.zip',
|
|
|
'mime' => 'application/zip',
|
|
|
];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<string, mixed> $payload
|
|
|
*/
|
|
|
public static function writeDocxFile(array $payload): string
|
|
|
{
|
|
|
$phpWord = self::buildDocument($payload);
|
|
|
$base = tempnam(sys_get_temp_dir(), 'st-docx-');
|
|
|
if ($base === false) {
|
|
|
throw new \RuntimeException('无法创建临时文件');
|
|
|
}
|
|
|
$path = $base.'.docx';
|
|
|
@unlink($base);
|
|
|
|
|
|
$writer = IOFactory::createWriter($phpWord, 'Word2007');
|
|
|
$writer->save($path);
|
|
|
|
|
|
return $path;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<int, StudyTour> $tours
|
|
|
*/
|
|
|
public static function writeZipFile(array $tours): string
|
|
|
{
|
|
|
$base = tempnam(sys_get_temp_dir(), 'st-zip-');
|
|
|
if ($base === false) {
|
|
|
throw new \RuntimeException('无法创建临时文件');
|
|
|
}
|
|
|
$zipPath = $base.'.zip';
|
|
|
@unlink($base);
|
|
|
|
|
|
$zip = new ZipArchive();
|
|
|
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
|
|
throw new \RuntimeException('无法创建压缩包');
|
|
|
}
|
|
|
|
|
|
$usedNames = [];
|
|
|
$tempFiles = [];
|
|
|
try {
|
|
|
foreach ($tours as $tour) {
|
|
|
$payload = self::payloadFromTour($tour);
|
|
|
$docx = self::writeDocxFile($payload);
|
|
|
$tempFiles[] = $docx;
|
|
|
$entry = self::uniqueZipEntryName(self::docxFilename($tour), $usedNames);
|
|
|
$usedNames[$entry] = true;
|
|
|
$zip->addFile($docx, $entry);
|
|
|
}
|
|
|
$zip->close();
|
|
|
} catch (\Throwable $e) {
|
|
|
$zip->close();
|
|
|
foreach ($tempFiles as $file) {
|
|
|
if (is_string($file) && is_file($file)) {
|
|
|
@unlink($file);
|
|
|
}
|
|
|
}
|
|
|
if (is_file($zipPath)) {
|
|
|
@unlink($zipPath);
|
|
|
}
|
|
|
throw $e;
|
|
|
}
|
|
|
|
|
|
foreach ($tempFiles as $file) {
|
|
|
if (is_string($file) && is_file($file)) {
|
|
|
@unlink($file);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return $zipPath;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @return array<string, mixed>
|
|
|
*/
|
|
|
public static function payloadFromTour(StudyTour $tour): array
|
|
|
{
|
|
|
return [
|
|
|
'name' => (string) ($tour->name ?? ''),
|
|
|
'org_name' => (string) ($tour->org_name ?? ''),
|
|
|
'seasons' => array_values($tour->seasons ?? []),
|
|
|
'venue_names' => self::venueNames($tour),
|
|
|
'suitable_count' => (string) ($tour->suitable_count ?? ''),
|
|
|
'suitable_audience' => (string) ($tour->suitable_audience ?? ''),
|
|
|
'duration' => (string) ($tour->duration ?? ''),
|
|
|
'contact_person' => (string) ($tour->contact_person ?? ''),
|
|
|
'contact_phones' => (string) ($tour->contact_phones ?? ''),
|
|
|
'cover_image_path' => self::resolvePublicImagePath($tour->cover_image),
|
|
|
'intro_html' => (string) ($tour->intro_html ?? ''),
|
|
|
'route_plans' => array_values($tour->route_plans ?? []),
|
|
|
'courses' => array_values($tour->courses ?? []),
|
|
|
'fee_html' => (string) ($tour->fee_html ?? ''),
|
|
|
'implementation_html' => (string) ($tour->implementation_html ?? ''),
|
|
|
'season_options' => self::dictOptions('study_tour_season', self::SEASON_FALLBACK),
|
|
|
'audience_options' => self::dictOptions('study_tour_grade_level', self::AUDIENCE_FALLBACK),
|
|
|
];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<string, mixed> $payload
|
|
|
*/
|
|
|
public static function buildDocument(array $payload): PhpWord
|
|
|
{
|
|
|
$phpWord = new PhpWord();
|
|
|
$phpWord->setDefaultFontName('宋体');
|
|
|
$phpWord->setDefaultFontSize(11);
|
|
|
$phpWord->addTitleStyle(1, ['name' => '黑体', 'size' => 16, 'bold' => true], [
|
|
|
'spaceBefore' => 360,
|
|
|
'spaceAfter' => 160,
|
|
|
]);
|
|
|
|
|
|
$section = $phpWord->addSection([
|
|
|
'paperSize' => 'A4',
|
|
|
'marginTop' => 1134,
|
|
|
'marginBottom' => 1134,
|
|
|
'marginLeft' => 1134,
|
|
|
'marginRight' => 1134,
|
|
|
]);
|
|
|
|
|
|
$section->addText('研学线路申报表', ['name' => '黑体', 'size' => 18, 'bold' => true], [
|
|
|
'alignment' => Jc::CENTER,
|
|
|
'spaceAfter' => 240,
|
|
|
]);
|
|
|
|
|
|
$section->addTitle('一、线路基本情况', 1);
|
|
|
self::addBasicTable($section, $payload);
|
|
|
self::addCoverImage($section, $payload);
|
|
|
|
|
|
$section->addTitle('二、线路简介', 1);
|
|
|
self::addHtmlAsParagraphs($section, (string) ($payload['intro_html'] ?? ''));
|
|
|
|
|
|
$section->addTitle('三、线路规划', 1);
|
|
|
self::addRouteTable($section, is_array($payload['route_plans'] ?? null) ? $payload['route_plans'] : []);
|
|
|
|
|
|
$section->addTitle('四、研学课程', 1);
|
|
|
self::addCourseTable($section, is_array($payload['courses'] ?? null) ? $payload['courses'] : []);
|
|
|
|
|
|
$section->addTitle('五、线路收费标准', 1);
|
|
|
self::addHtmlAsParagraphs($section, (string) ($payload['fee_html'] ?? ''));
|
|
|
|
|
|
$section->addTitle('六、线路计划实施情况', 1);
|
|
|
self::addHtmlAsParagraphs($section, (string) ($payload['implementation_html'] ?? ''));
|
|
|
|
|
|
return $phpWord;
|
|
|
}
|
|
|
|
|
|
public static function htmlToPlainText(string $html): string
|
|
|
{
|
|
|
$html = trim($html);
|
|
|
if ($html === '') {
|
|
|
return '';
|
|
|
}
|
|
|
$html = preg_replace('/<(br|BR)\s*\/?>/u', "\n", $html) ?? $html;
|
|
|
$html = preg_replace('/<\/(p|div|li|h[1-6]|tr)[^>]*>/iu', "\n", $html) ?? $html;
|
|
|
$html = preg_replace('/<li[^>]*>/iu', "· ", $html) ?? $html;
|
|
|
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
|
|
|
return StudyTourPayload::compactMultilineText($text);
|
|
|
}
|
|
|
|
|
|
public static function sanitizeFilename(string $name): string
|
|
|
{
|
|
|
$name = StudyTourPayload::compactText($name);
|
|
|
$name = preg_replace('/[\\\\\/:\*\?"<>\|\r\n]+/u', '_', $name) ?? $name;
|
|
|
$name = trim($name, ' ._');
|
|
|
if ($name === '') {
|
|
|
$name = '研学线路';
|
|
|
}
|
|
|
|
|
|
return mb_substr($name, 0, 80);
|
|
|
}
|
|
|
|
|
|
public static function resolvePublicImagePath(?string $raw): ?string
|
|
|
{
|
|
|
$raw = trim((string) $raw);
|
|
|
if ($raw === '') {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
$path = $raw;
|
|
|
if (preg_match('#^https?://#i', $raw)) {
|
|
|
$path = (string) (parse_url($raw, PHP_URL_PATH) ?: '');
|
|
|
}
|
|
|
$path = rawurldecode($path);
|
|
|
$path = str_replace('\\', '/', $path);
|
|
|
|
|
|
if (str_starts_with($path, '/storage/')) {
|
|
|
$rel = ltrim(substr($path, strlen('/storage/')), '/');
|
|
|
} elseif (str_starts_with($path, 'storage/')) {
|
|
|
$rel = ltrim(substr($path, strlen('storage/')), '/');
|
|
|
} else {
|
|
|
$rel = ltrim($path, '/');
|
|
|
}
|
|
|
|
|
|
if ($rel === '' || str_contains($rel, '..')) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
$full = storage_path('app/public/'.$rel);
|
|
|
$real = realpath($full);
|
|
|
$root = realpath(storage_path('app/public'));
|
|
|
if ($real === false || $root === false || ! is_file($real)) {
|
|
|
return null;
|
|
|
}
|
|
|
if ($real !== $root && ! str_starts_with($real, $root.DIRECTORY_SEPARATOR)) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
return $real;
|
|
|
}
|
|
|
|
|
|
private static function docxFilename(StudyTour $tour): string
|
|
|
{
|
|
|
$stem = self::sanitizeFilename((string) ($tour->name ?: '研学线路-'.$tour->id));
|
|
|
|
|
|
return $stem.'.docx';
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<string, true> $usedNames
|
|
|
*/
|
|
|
private static function uniqueZipEntryName(string $filename, array $usedNames): string
|
|
|
{
|
|
|
if (! isset($usedNames[$filename])) {
|
|
|
return $filename;
|
|
|
}
|
|
|
$stem = preg_replace('/\.docx$/i', '', $filename) ?? $filename;
|
|
|
$i = 2;
|
|
|
do {
|
|
|
$candidate = $stem.'-'.$i.'.docx';
|
|
|
$i++;
|
|
|
} while (isset($usedNames[$candidate]));
|
|
|
|
|
|
return $candidate;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @return array<int, string>
|
|
|
*/
|
|
|
private static function venueNames(StudyTour $tour): array
|
|
|
{
|
|
|
$items = StudyTourPayload::venueItemsForRecord($tour);
|
|
|
$systemIds = collect($items)
|
|
|
->filter(fn ($item) => ($item['type'] ?? '') === 'system')
|
|
|
->map(fn ($item) => (int) ($item['venue_id'] ?? 0))
|
|
|
->filter(fn ($id) => $id > 0)
|
|
|
->values()
|
|
|
->all();
|
|
|
$venueMap = $systemIds === []
|
|
|
? collect()
|
|
|
: Venue::query()->whereIn('id', $systemIds)->get(['id', 'name'])->keyBy('id');
|
|
|
|
|
|
$names = [];
|
|
|
foreach ($items as $item) {
|
|
|
if (($item['type'] ?? '') === 'system') {
|
|
|
$id = (int) ($item['venue_id'] ?? 0);
|
|
|
$name = trim((string) ($venueMap->get($id)?->name ?? ''));
|
|
|
if ($name !== '') {
|
|
|
$names[] = $name;
|
|
|
}
|
|
|
continue;
|
|
|
}
|
|
|
$name = StudyTourPayload::compactText((string) ($item['name'] ?? ''));
|
|
|
if ($name !== '') {
|
|
|
$names[] = $name;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return $names;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<int, array{value: string, label: string}> $fallback
|
|
|
* @return array<int, array{value: string, label: string}>
|
|
|
*/
|
|
|
private static function dictOptions(string $dictType, array $fallback): array
|
|
|
{
|
|
|
try {
|
|
|
$options = DictItem::activeOptions($dictType);
|
|
|
if ($options !== []) {
|
|
|
return $options;
|
|
|
}
|
|
|
} catch (\Throwable) {
|
|
|
// tests or missing table: use fallback
|
|
|
}
|
|
|
|
|
|
return $fallback;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<string, mixed> $payload
|
|
|
*/
|
|
|
private static function addBasicTable($section, array $payload): void
|
|
|
{
|
|
|
$table = $section->addTable(self::tableStyle());
|
|
|
$rows = [
|
|
|
['组织单位名称', (string) ($payload['org_name'] ?? '')],
|
|
|
['线路名称', (string) ($payload['name'] ?? '')],
|
|
|
['对应季节(可多选)', self::checkboxLine(
|
|
|
is_array($payload['season_options'] ?? null) ? $payload['season_options'] : self::SEASON_FALLBACK,
|
|
|
is_array($payload['seasons'] ?? null) ? $payload['seasons'] : []
|
|
|
)],
|
|
|
['线路点位', implode('、', array_values($payload['venue_names'] ?? []))],
|
|
|
['适宜人数', (string) ($payload['suitable_count'] ?? '')],
|
|
|
['适配学段(可多选)', self::audienceLine($payload)],
|
|
|
['研学时长', (string) ($payload['duration'] ?? '')],
|
|
|
['线路联络人', (string) ($payload['contact_person'] ?? '')],
|
|
|
['咨询电话', (string) ($payload['contact_phones'] ?? '')],
|
|
|
];
|
|
|
|
|
|
foreach ($rows as [$label, $value]) {
|
|
|
$table->addRow();
|
|
|
self::addLabelCell($table, $label);
|
|
|
self::addValueCell($table, $value);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<string, mixed> $payload
|
|
|
*/
|
|
|
private static function addCoverImage($section, array $payload): void
|
|
|
{
|
|
|
$path = $payload['cover_image_path'] ?? null;
|
|
|
$section->addText('封面图', ['bold' => true], ['spaceBefore' => 200, 'spaceAfter' => 80]);
|
|
|
if (! is_string($path) || $path === '' || ! is_file($path)) {
|
|
|
$section->addText('(未设置封面图)', ['color' => '86909C'], ['spaceAfter' => 200]);
|
|
|
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
$size = self::coverImageSize($path);
|
|
|
try {
|
|
|
$section->addImage($path, [
|
|
|
'width' => $size['width'],
|
|
|
'height' => $size['height'],
|
|
|
'wrappingStyle' => 'inline',
|
|
|
]);
|
|
|
} catch (\Throwable) {
|
|
|
$section->addText('(封面图无法嵌入文档)', ['color' => '86909C']);
|
|
|
}
|
|
|
$section->addTextBreak(1);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @return array{width: int, height: int}
|
|
|
*/
|
|
|
private static function coverImageSize(string $path): array
|
|
|
{
|
|
|
$maxW = 360;
|
|
|
$maxH = 240;
|
|
|
$info = @getimagesize($path);
|
|
|
$w = is_array($info) ? (int) ($info[0] ?? 0) : 0;
|
|
|
$h = is_array($info) ? (int) ($info[1] ?? 0) : 0;
|
|
|
if ($w <= 0 || $h <= 0) {
|
|
|
return ['width' => $maxW, 'height' => 180];
|
|
|
}
|
|
|
$scale = min($maxW / $w, $maxH / $h, 1);
|
|
|
|
|
|
return [
|
|
|
'width' => (int) max(1, round($w * $scale)),
|
|
|
'height' => (int) max(1, round($h * $scale)),
|
|
|
];
|
|
|
}
|
|
|
|
|
|
private static function addHtmlAsParagraphs($section, string $html): void
|
|
|
{
|
|
|
$text = self::htmlToPlainText($html);
|
|
|
if ($text === '') {
|
|
|
$section->addText('(暂无)', ['color' => '86909C'], ['spaceAfter' => 160]);
|
|
|
|
|
|
return;
|
|
|
}
|
|
|
foreach (preg_split('/\R/u', $text) ?: [] as $line) {
|
|
|
$line = trim($line);
|
|
|
if ($line === '') {
|
|
|
continue;
|
|
|
}
|
|
|
$section->addText($line, [], ['spaceAfter' => 80]);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<int, mixed> $plans
|
|
|
*/
|
|
|
private static function addRouteTable($section, array $plans): void
|
|
|
{
|
|
|
$table = $section->addTable(self::tableStyle());
|
|
|
$table->addRow();
|
|
|
foreach (['日期', '时间', '行程安排', '地点'] as $header) {
|
|
|
self::addHeaderCell($table, $header);
|
|
|
}
|
|
|
|
|
|
$hasRow = false;
|
|
|
foreach ($plans as $plan) {
|
|
|
if (! is_array($plan)) {
|
|
|
continue;
|
|
|
}
|
|
|
$dateLabel = StudyTourPayload::compactText((string) ($plan['date_label'] ?? ''));
|
|
|
$items = is_array($plan['items'] ?? null) ? $plan['items'] : [];
|
|
|
if ($items === []) {
|
|
|
$table->addRow();
|
|
|
self::addValueCell($table, $dateLabel, 1800);
|
|
|
self::addValueCell($table, '', 1400);
|
|
|
self::addValueCell($table, '', 3600);
|
|
|
self::addValueCell($table, '', 2200);
|
|
|
$hasRow = true;
|
|
|
continue;
|
|
|
}
|
|
|
foreach ($items as $item) {
|
|
|
if (! is_array($item)) {
|
|
|
continue;
|
|
|
}
|
|
|
$table->addRow();
|
|
|
self::addValueCell($table, $dateLabel, 1800);
|
|
|
self::addValueCell($table, (string) ($item['time'] ?? ''), 1400);
|
|
|
self::addValueCell($table, (string) ($item['activity'] ?? ''), 3600);
|
|
|
self::addValueCell($table, (string) ($item['location'] ?? ''), 2200);
|
|
|
$hasRow = true;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (! $hasRow) {
|
|
|
$table->addRow();
|
|
|
self::addValueCell($table, '', 1800);
|
|
|
self::addValueCell($table, '', 1400);
|
|
|
self::addValueCell($table, '', 3600);
|
|
|
self::addValueCell($table, '', 2200);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<int, mixed> $courses
|
|
|
*/
|
|
|
private static function addCourseTable($section, array $courses): void
|
|
|
{
|
|
|
$table = $section->addTable(self::tableStyle());
|
|
|
$table->addRow();
|
|
|
foreach (['序号', '课程名称', '课程内容'] as $header) {
|
|
|
self::addHeaderCell($table, $header);
|
|
|
}
|
|
|
|
|
|
$rows = [];
|
|
|
foreach ($courses as $idx => $course) {
|
|
|
if (! is_array($course)) {
|
|
|
continue;
|
|
|
}
|
|
|
$rows[] = [
|
|
|
(string) ((int) ($course['sort'] ?? ($idx + 1))),
|
|
|
(string) ($course['name'] ?? ''),
|
|
|
(string) ($course['content'] ?? ''),
|
|
|
];
|
|
|
}
|
|
|
if ($rows === []) {
|
|
|
$rows[] = ['', '', ''];
|
|
|
}
|
|
|
foreach ($rows as [$sort, $name, $content]) {
|
|
|
$table->addRow();
|
|
|
self::addValueCell($table, $sort, 900);
|
|
|
self::addValueCell($table, $name, 2800);
|
|
|
self::addValueCell($table, $content, 5300);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @return array<string, mixed>
|
|
|
*/
|
|
|
private static function tableStyle(): array
|
|
|
{
|
|
|
return [
|
|
|
'borderSize' => 6,
|
|
|
'borderColor' => '000000',
|
|
|
'cellMargin' => 60,
|
|
|
'width' => 5000,
|
|
|
'unit' => TblWidth::PERCENT,
|
|
|
];
|
|
|
}
|
|
|
|
|
|
private static function addLabelCell($table, string $text): void
|
|
|
{
|
|
|
$cell = $table->addCell(2200, [
|
|
|
'bgColor' => 'F2F3F5',
|
|
|
'valign' => VerticalJc::CENTER,
|
|
|
]);
|
|
|
$cell->addText($text, ['bold' => true]);
|
|
|
}
|
|
|
|
|
|
private static function addHeaderCell($table, string $text): void
|
|
|
{
|
|
|
$cell = $table->addCell(null, [
|
|
|
'bgColor' => 'F2F3F5',
|
|
|
'valign' => VerticalJc::CENTER,
|
|
|
]);
|
|
|
$cell->addText($text, ['bold' => true], ['alignment' => Jc::CENTER]);
|
|
|
}
|
|
|
|
|
|
private static function addValueCell($table, string $text, ?int $width = 6800): void
|
|
|
{
|
|
|
$cell = $table->addCell($width ?? 6800, ['valign' => VerticalJc::CENTER]);
|
|
|
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
|
|
$lines = preg_split('/\n/u', $text) ?: [$text];
|
|
|
$wrote = false;
|
|
|
foreach ($lines as $line) {
|
|
|
$line = trim((string) $line);
|
|
|
if ($line === '') {
|
|
|
continue;
|
|
|
}
|
|
|
$cell->addText($line);
|
|
|
$wrote = true;
|
|
|
}
|
|
|
if (! $wrote) {
|
|
|
$cell->addText('');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<int, array{value: string, label: string}> $options
|
|
|
* @param array<int, mixed> $selected
|
|
|
*/
|
|
|
private static function checkboxLine(array $options, array $selected): string
|
|
|
{
|
|
|
$set = [];
|
|
|
foreach ($selected as $value) {
|
|
|
$set[trim((string) $value)] = true;
|
|
|
}
|
|
|
$parts = [];
|
|
|
foreach ($options as $opt) {
|
|
|
$value = (string) ($opt['value'] ?? '');
|
|
|
$label = (string) ($opt['label'] ?? $value);
|
|
|
if ($label === '') {
|
|
|
continue;
|
|
|
}
|
|
|
$mark = isset($set[$value]) ? '☑' : '☐';
|
|
|
$parts[] = $mark.$label;
|
|
|
}
|
|
|
|
|
|
return implode(' ', $parts);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<string, mixed> $payload
|
|
|
*/
|
|
|
private static function audienceLine(array $payload): string
|
|
|
{
|
|
|
$raw = StudyTourPayload::compactText((string) ($payload['suitable_audience'] ?? ''));
|
|
|
$options = is_array($payload['audience_options'] ?? null) ? $payload['audience_options'] : self::AUDIENCE_FALLBACK;
|
|
|
if ($raw === '') {
|
|
|
return self::checkboxLine($options, []);
|
|
|
}
|
|
|
|
|
|
$parts = preg_split('/[,,、;;\s]+/u', $raw) ?: [];
|
|
|
$parts = array_values(array_filter(array_map('trim', $parts), fn ($p) => $p !== ''));
|
|
|
$valueSet = [];
|
|
|
foreach ($options as $opt) {
|
|
|
$valueSet[(string) $opt['value']] = true;
|
|
|
$valueSet[(string) $opt['label']] = (string) $opt['value'];
|
|
|
}
|
|
|
|
|
|
$selected = [];
|
|
|
$unmatched = [];
|
|
|
foreach ($parts as $part) {
|
|
|
if (isset($valueSet[$part]) && $valueSet[$part] === true) {
|
|
|
$selected[] = $part;
|
|
|
continue;
|
|
|
}
|
|
|
if (isset($valueSet[$part]) && is_string($valueSet[$part])) {
|
|
|
$selected[] = $valueSet[$part];
|
|
|
continue;
|
|
|
}
|
|
|
$unmatched[] = $part;
|
|
|
}
|
|
|
|
|
|
if ($selected === [] && $unmatched !== []) {
|
|
|
return $raw;
|
|
|
}
|
|
|
|
|
|
$line = self::checkboxLine($options, $selected);
|
|
|
if ($unmatched !== []) {
|
|
|
$extra = implode(',', $unmatched);
|
|
|
$line = $line === '' ? $extra : $line.' '.$extra;
|
|
|
}
|
|
|
|
|
|
return $line;
|
|
|
}
|
|
|
}
|