self::MAX_BYTES) { throw new ExtractionException('too_large', 'The content is larger than the 2 MB limit.'); } if ($type === 'pdf') { if (!PdfSupport::available()) { throw new ExtractionException('pdf_unavailable', 'PDF files are not supported in this edition of Aiwend.'); } return new Document($this->clean($title) ?: 'Untitled', (new PdfExtractor())->extract($raw)); } if (str_contains($raw, "\0") || !mb_check_encoding($raw, 'UTF-8')) { throw new ExtractionException('binary', 'The content is not valid UTF-8 text.'); } $raw = (string) preg_replace('/^\xEF\xBB\xBF/', '', $raw); // BOM $sections = match ($type) { 'markdown' => $this->markdownSections($raw), 'html' => $this->htmlSections($raw), default => $this->textSections($raw), }; $sections = array_values(array_filter( array_map(fn(array $s): array => ['anchor' => $s['anchor'], 'heading' => $this->clean($s['heading']), 'text' => $this->clean($s['text'])], $sections), static fn(array $s): bool => $s['text'] !== '' && preg_match('/[\p{L}\p{N}]/u', $s['text']) !== 0 // a section with no letter or digit (a lone "#", a rule, a symbol) has nothing to retrieve; a regex error (false) keeps the text rather than losing it )); if ($sections === []) { throw new ExtractionException('empty', 'No readable text was found.'); } return new Document($this->clean($title) ?: 'Untitled', $sections); } /** @return list */ private function textSections(string $raw): array { return [['anchor' => 'section-1', 'heading' => '', 'text' => $raw]]; } /** @return list */ private function markdownSections(string $raw): array { $sections = []; $heading = ''; $buffer = []; $used = []; $n = 0; $flush = function () use (&$sections, &$heading, &$buffer, &$used, &$n): void { $text = trim(implode("\n", $buffer)); if ($text !== '' || $heading !== '') { $n++; $sections[] = ['anchor' => $this->uniqueSlug($heading, $n, $used), 'heading' => $heading, 'text' => $text]; } $buffer = []; }; $inFence = false; foreach (preg_split('/\R/u', $raw) ?: [] as $line) { if (preg_match('/^\s*(```|~~~)/', $line)) { $inFence = !$inFence; $buffer[] = $line; continue; } if (!$inFence && preg_match('/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/u', $line, $m)) { $flush(); $heading = $this->stripInlineMarkdown($m[2]); continue; } $buffer[] = $this->stripInlineMarkdown($line); } $flush(); return $sections; } /** @return list */ private function htmlSections(string $raw): array { // Drop content that is never visible text. $raw = (string) preg_replace('#<(script|style|noscript|template|svg|iframe)\b[^>]*>.*?#is', ' ', $raw); $raw = (string) preg_replace('##s', ' ', $raw); $parts = preg_split('#]*)>(.*?)#is', $raw, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [$raw]; $sections = []; $used = []; $n = 0; $heading = ''; $explicitId = ''; for ($i = 0; $i < count($parts); $i++) { if ($i % 4 === 0) { $text = $this->htmlToText($parts[$i]); if (trim($text) !== '' || $heading !== '') { $n++; // Only an id that exists in the HTML can be linked as #fragment; otherwise use a // non-linkable ordinal (headings in WordPress content usually have no id). $anchor = $explicitId !== '' && !isset($used[$explicitId]) ? $explicitId : 'section-' . $n; $used[$anchor] = true; $sections[] = ['anchor' => $anchor, 'heading' => $heading, 'text' => $text]; } continue; } if ($i % 4 === 2) { // Prefer an existing id="" on the heading: then the citation link really resolves. $explicitId = preg_match('/\bid\s*=\s*["\']([A-Za-z][\w\-:.]{0,100})["\']/', $parts[$i], $m) ? $m[1] : ''; } if ($i % 4 === 3) { $heading = trim($this->htmlToText($parts[$i])); } } return $sections; } private function htmlToText(string $html): string { // Block-level boundaries become line breaks and table cells are separated by a space, so that "Plan" and "Price" in a table, or a // term and its definition, do not run together into one word that no question could match (Phase 115). $html = (string) preg_replace('#<(br|hr|/p|/div|/li|/tr|/h[1-6]|/blockquote|/section|/article|/header|/footer|/aside|/figure|/figcaption|/caption|/dt|/dd|/ul|/ol|/table|/pre|/details|/summary)\b[^>]*>#i', "\n", $html); $html = (string) preg_replace('##i', ' ', $html); $html = (string) preg_replace('#]*>#i', "\n- ", $html); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- pure PHP extractor without a WordPress dependency; the result is plain text escaped on output $text = strip_tags($html); return html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } private function stripInlineMarkdown(string $line): string { $line = (string) preg_replace('/!\[([^\]]*)\]\([^)]*\)/u', '$1', $line); // images → alt text $line = (string) preg_replace('/\[([^\]]+)\]\([^)]*\)/u', '$1', $line); // links → label return (string) preg_replace('/(\*\*|__|`)/u', '', $line); } /** @param array $used */ private function uniqueSlug(string $heading, int $n, array &$used): string { $slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($this->asciiFold($heading))), '-'); $slug = $slug === '' ? 'section-' . $n : substr($slug, 0, 80); $base = $slug; for ($k = 2; isset($used[$slug]); $k++) { $slug = $base . '-' . $k; } $used[$slug] = true; return $slug; } private function asciiFold(string $s): string { if (function_exists('transliterator_transliterate')) { $t = transliterator_transliterate('Any-Latin; Latin-ASCII', $s); if (is_string($t)) { return $t; } } return $s; } private function clean(string $text): string { if (class_exists(\Normalizer::class)) { $text = (string) \Normalizer::normalize($text, \Normalizer::FORM_C); } $text = (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text); $text = (string) preg_replace('/[ \t\x{00A0}]+/u', ' ', $text); $text = (string) preg_replace('/ *\n */u', "\n", $text); $text = (string) preg_replace('/\n{3,}/u', "\n\n", $text); return trim($text); } }