/{story.docx, story.pdf, images/*}) into the usecase model. * * The docx files share one structure (verified on Edinburgh / Woori): * 1st paragraph -> title * 2nd paragraph -> subtitle (one-liner) * 1st IntenseQuote -> record quote, the two short lines after it * quote_person / quote_role * known section names -> text elements (Background, The Challenge, * The Solution, The Result, ... - plain * paragraphs, no heading styles in the files) * ListParagraph runs ->
' . htmlspecialchars($value, ENT_NOQUOTES | ENT_HTML5) . '
'; } if ($html !== '' || $current !== null) { $sections[] = ['type' => 'text', 'header' => (string)$current, 'body' => $html]; } $body = []; }; for ($i = $index; $i < count($paragraphs); $i++) { $paragraph = $paragraphs[$i]; $lower = mb_strtolower(rtrim($paragraph['text'], ':')); if ($paragraph['style'] !== 'IntenseQuote' && in_array($lower, self::SECTION_NAMES, true)) { $flushBody(); $current = $paragraph['text']; continue; } if ($paragraph['style'] === 'IntenseQuote') { // attribution BEFORE the quote ("... said:" / "... continues:") $name = ''; $position = ''; $last = $body !== [] ? $body[count($body) - 1] : null; if ($last !== null && $last[0] === 'p' && preg_match('/^(.{3,90}?)\s+(said|says|continues|adds|explains)\s*:?\s*$/iu', $last[1], $m)) { [$name, $position] = $this->splitAttribution($m[1]); array_pop($body); } elseif (isset($paragraphs[$i + 1]) && $paragraphs[$i + 1]['style'] !== 'IntenseQuote') { $next = $paragraphs[$i + 1]['text']; $nextLower = mb_strtolower(rtrim($next, ':')); if (mb_strlen($next) <= 90 && str_contains($next, ',') && !in_array($nextLower, self::SECTION_NAMES, true)) { [$name, $position] = $this->splitAttribution($next); $i++; } } $flushBody(); $current = null; $sections[] = ['type' => 'quote', 'quote' => $this->unquote($paragraph['text']), 'name' => $name, 'position' => $position]; $quotes[] = 1; continue; } if ($paragraph['style'] === 'ListParagraph') { $last = $body !== [] ? count($body) - 1 : -1; if ($last >= 0 && $body[$last][0] === 'ul') { $body[$last][1][] = $paragraph['text']; } else { $body[] = ['ul', [$paragraph['text']]]; } continue; } if ($current === null && $sections === []) { $report[] = 'HINWEIS: Absatz vor erster Sektion: "' . mb_substr($paragraph['text'], 0, 50) . '..."'; } $body[] = ['p', $paragraph['text']]; } $flushBody(); // teaser from the first text section $teaser = ''; foreach ($sections as $section) { if ($section['type'] === 'text' && preg_match('/(.*?)<\/p>/s', $section['body'], $m)) { $teaser = html_entity_decode($m[1], ENT_NOQUOTES | ENT_HTML5); if (mb_strlen($teaser) > 300) { $teaser = mb_substr($teaser, 0, 300); $teaser = mb_substr($teaser, 0, (int)mb_strrpos($teaser, ' ')) . ' ...'; } break; } } return ['title' => $title, 'subtitle' => $subtitle, 'teaser' => $teaser, 'lead' => $lead, 'sections' => $sections, 'quotes' => $quotes, 'report' => $report]; } /** @return array{0:string,1:string} name, position */ private function splitAttribution(string $value): array { $parts = explode(',', $value, 2); return [trim($parts[0]), trim($parts[1] ?? '')]; } private function unquote(string $value): string { // multibyte-safe strip of straight and curly quotes at both ends return trim((string)preg_replace('/^[\s"\'\x{201C}\x{201D}\x{2018}\x{2019}]+|[\s"\'\x{201C}\x{201D}\x{2018}\x{2019}]+$/u', '', $value)); } private function slugify(string $value): string { $value = str_replace(['+', '&'], ['plus', 'and'], mb_strtolower(trim($value))); return trim((string)preg_replace('/[^a-z0-9]+/', '-', $value), '-'); } private function norm(string $value): string { return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value))); } private function newId(): string { return 'NEWDS' . (++$this->newCounter); } }