`(高亮,点击看内容); * 没有位置的 issue 合成一个三角叹号图标,放在句尾; * - note / commentary:在插入点插入调用方预先渲染好的 `{{note|…}}` 模板。 * * 交叠的处理(核心约束:任何时候都不能切断一个标记): * - 受保护区间:content 里本来就有的标记(`[[…]]`、`{{…}}`、`` `…` ``、`**…**`、HTML 标签), * 谁都不能切断; * - glossary 碰到受保护区间就放弃;两个 glossary 交叠时,包含关系留外层,交叉关系留靠前的, * 另一条放弃(`[[…]]` 不能嵌套,也不能交叉); * - issue 与 glossary / 受保护区间部分交叠时,把 issue 扩展到完整包住对方; * issue 之间交叠或包含就合并成一个 span,id 取并集、severity 取最重的; * 扩展可能引出新的交叠,反复处理直到稳定。仅仅首尾相接的不合并; * - note 的插入点落在 glossary 或受保护区间内部时,挪到该区间的末尾;落在 issue 内部则原地插入 * (行内元素嵌套是合法的); * - 同一位置上的输出顺序:先收尾的 issue ``,再 note,再开头的 issue ``—— * note 锚定的是位置之前的文字,应当跟在结束的高亮之后、开始的高亮之前。 */ class AnnotationLayout { /** 严重程度由轻到重;合并时取最重的,没有 severity 的最轻 */ private const SEVERITY_RANK = ['suggestion' => 1, 'warning' => 2, 'error' => 3]; /** glossary 的原文或词头里不能有这些字符,否则 `[[title#原文]]` 会被拆坏 */ private const GLOSSARY_FORBIDDEN = '/[\[\]{}|#\r\n]/u'; /** * 受保护区间:已有的模板、术语、行内注释、粗体与 HTML 标签。 */ private const PROTECTED_PATTERN = '/\[\[.*?\]\]|\{\{.*?\}\}|`[^`]*`|\*\*.+?\*\*|<[^<>]+>/us'; /** * 按选择器在 content 里定位一段文字,返回 [start, end)(字符偏移),定位不到返回 null。 * * 先信位置:pos 合法且(没有摘录或摘录对得上)就用 pos。对不上说明正文改过,按摘录重新找: * 有前后缀就优先选前后缀也吻合的那一处,同分取离原位置最近的。 * * @return array{0: int, 1: int}|null */ public static function locate( string $content, ?int $start, ?int $end, ?string $exact = null, ?string $prefix = null, ?string $suffix = null, ): ?array { $len = mb_strlen($content, 'UTF-8'); $exact = (string) $exact; $posValid = $start !== null && $end !== null && $start >= 0 && $start < $end && $end <= $len; if ($posValid && ($exact === '' || mb_substr($content, $start, $end - $start, 'UTF-8') === $exact)) { return [$start, $end]; } if ($exact === '') { return null; } $exactLen = mb_strlen($exact, 'UTF-8'); $prefix = (string) $prefix; $suffix = (string) $suffix; $best = null; $bestScore = null; $offset = 0; while (($found = mb_strpos($content, $exact, $offset, 'UTF-8')) !== false) { // 前缀:紧挨在前面的文字是前缀的结尾(句首附近可能不够长,比能比的那部分) $n = min($found, mb_strlen($prefix, 'UTF-8')); $before = mb_substr($content, $found - $n, $n, 'UTF-8'); $matchPrefix = $n > 0 && str_ends_with($prefix, $before); // 后缀:紧跟在后面的文字是后缀的开头 $after = mb_substr($content, $found + $exactLen, mb_strlen($suffix, 'UTF-8'), 'UTF-8'); $matchSuffix = $after !== '' && str_starts_with($suffix, $after); $distance = $start === null ? $found : abs($found - $start); // 分数:前后缀吻合得越多越好,其次离原位置越近越好 $score = [(int) $matchPrefix + (int) $matchSuffix, -$distance]; if ($bestScore === null || $score > $bestScore) { $best = $found; $bestScore = $score; } $offset = $found + 1; } return $best === null ? null : [$best, $best + $exactLen]; } /** * 排版。 * * @param list $glossaries 已定位的术语 * @param list $issues start/end 为 null 表示没有位置 * @param list, html: string}> $notes at 是插入点,order 是同一插入点上的先后 * @param bool $withIssues 是否输出 issue 的高亮与图标(只有 html 格式需要) * @return array{content: string, notes: list, glossaries: list, issues: list, severity: string|null, start: int|null, end: int|null}>} * * 返回值里 notes 是 note id 的阅读顺序,glossaries 是实际替换了的术语 id, * issues 是合并后的高亮(没有位置的合成一条,start / end 为 null)。 */ public static function apply(string $content, array $glossaries, array $issues, array $notes, bool $withIssues = true): array { $len = mb_strlen($content, 'UTF-8'); $protected = self::protectedRanges($content); // ---- glossary:放弃碰到受保护区间的、字符不合法的,然后贪心取不交叠的 $glossaries = array_values(array_filter($glossaries, function (array $g) use ($content, $len, $protected) { if ($g['start'] < 0 || $g['start'] >= $g['end'] || $g['end'] > $len) { return false; } $text = mb_substr($content, $g['start'], $g['end'] - $g['start'], 'UTF-8'); if (trim($g['title']) === '' || preg_match(self::GLOSSARY_FORBIDDEN, $text.$g['title'])) { return false; } foreach ($protected as $p) { if (self::intersects($g, $p)) { return false; } } return true; })); // 开头靠前的优先;开头相同的,长的(外层)优先 usort($glossaries, fn ($a, $b) => [$a['start'], $b['end'], $a['id']] <=> [$b['start'], $a['end'], $b['id']]); $kept = []; foreach ($glossaries as $g) { foreach ($kept as $k) { if (self::intersects($g, $k)) { continue 2; } } $kept[] = $g; } $glossaries = $kept; // ---- issue:扩展到不切断任何 glossary / 受保护区间,再合并交叠的,直到稳定 $spans = []; $unanchored = []; foreach ($issues as $issue) { $s = $issue['start']; $e = $issue['end']; if ($s === null || $e === null || $s < 0 || $s >= $e || $e > $len) { $unanchored[] = $issue; continue; } $spans[] = ['start' => $s, 'end' => $e, 'ids' => [$issue['id']], 'severity' => $issue['severity']]; } $spans = self::settleSpans($spans, [...$glossaries, ...$protected]); // ---- note:插入点不能落在 glossary / 受保护区间内部 $blockers = [...$glossaries, ...$protected]; foreach ($notes as &$note) { $at = min(max($note['at'], 0), $len); do { $moved = false; foreach ($blockers as $b) { if ($b['start'] < $at && $at < $b['end']) { $at = $b['end']; $moved = true; } } } while ($moved); $note['at'] = $at; } unset($note); usort($notes, fn ($a, $b) => [$a['at'], ...$a['order']] <=> [$b['at'], ...$b['order']]); // ---- 按位置从左到右输出 $opens = []; $closes = []; if ($withIssues) { foreach ($spans as $span) { $opens[$span['start']][] = $span; $closes[$span['end']][] = $span; } } $notesAt = []; foreach ($notes as $note) { $notesAt[$note['at']][] = $note['html']; } $glossaryAt = []; foreach ($glossaries as $g) { $glossaryAt[$g['start']] = $g; } $points = array_unique([0, $len, ...array_keys($opens), ...array_keys($closes), ...array_keys($notesAt), ...array_keys($glossaryAt)]); sort($points); $out = ''; $cursor = 0; foreach ($points as $x) { if ($x < $cursor) { continue; // 落在已整段输出的 glossary 内部:构造上不会出现,只是防御 } $out .= mb_substr($content, $cursor, $x - $cursor, 'UTF-8'); $cursor = $x; // 合并后 issue 之间互不交叠,同一点上至多一个收尾、一个开头 foreach ($closes[$x] ?? [] as $span) { $out .= ''; } foreach ($notesAt[$x] ?? [] as $html) { $out .= $html; } foreach ($opens[$x] ?? [] as $span) { $out .= self::issueOpenTag($span['ids'], $span['severity']); } if (isset($glossaryAt[$x])) { $g = $glossaryAt[$x]; $text = mb_substr($content, $g['start'], $g['end'] - $g['start'], 'UTF-8'); $out .= '[['.trim($g['title']).'#'.$text.']]'; $cursor = $g['end']; } } $out .= mb_substr($content, $cursor, null, 'UTF-8'); if ($withIssues && $unanchored !== []) { $out .= self::issueMark( array_column($unanchored, 'id'), self::maxSeverity(array_column($unanchored, 'severity')), ); } $report = array_map(fn ($s) => [ 'ids' => $s['ids'], 'severity' => $s['severity'], 'start' => $s['start'], 'end' => $s['end'], ], $spans); if ($unanchored !== []) { $report[] = [ 'ids' => array_column($unanchored, 'id'), 'severity' => self::maxSeverity(array_column($unanchored, 'severity')), 'start' => null, 'end' => null, ]; } return [ 'content' => $out, 'notes' => array_column($notes, 'id'), 'glossaries' => array_column($glossaries, 'id'), 'issues' => $report, ]; } /** * content 里已有的标记占据的区间(字符偏移)。 * * @return list */ public static function protectedRanges(string $content): array { preg_match_all(self::PROTECTED_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE); $ranges = []; foreach ($matches[0] as [$text, $byteOffset]) { $start = mb_strlen(substr($content, 0, $byteOffset), 'UTF-8'); $ranges[] = ['start' => $start, 'end' => $start + mb_strlen($text, 'UTF-8')]; } return $ranges; } /** * 把 issue 区间扩展到不切断任何障碍物,并合并交叠的 issue,反复直到稳定。 * * @param list, severity: string|null}> $spans * @param list $blockers * @return list, severity: string|null}> */ private static function settleSpans(array $spans, array $blockers): array { do { $changed = false; // 扩展:与障碍物交叠但没有完整包住它,就把它包进来 foreach ($spans as &$span) { foreach ($blockers as $b) { if (self::intersects($span, $b) && ($b['start'] < $span['start'] || $b['end'] > $span['end'])) { $span['start'] = min($span['start'], $b['start']); $span['end'] = max($span['end'], $b['end']); $changed = true; } } } unset($span); // 合并:按开头排序后,与上一个交叠(含包含)的并进去 usort($spans, fn ($a, $b) => [$a['start'], $a['end']] <=> [$b['start'], $b['end']]); $merged = []; foreach ($spans as $span) { $last = count($merged) - 1; if ($last >= 0 && self::intersects($merged[$last], $span)) { $merged[$last]['end'] = max($merged[$last]['end'], $span['end']); $merged[$last]['ids'] = [...$merged[$last]['ids'], ...$span['ids']]; $merged[$last]['severity'] = self::maxSeverity([$merged[$last]['severity'], $span['severity']]); $changed = true; } else { $merged[] = $span; } } $spans = $merged; } while ($changed); return $spans; } /** * 两个半开区间是否有公共部分(首尾相接不算)。 * * @param array{start: int, end: int} $a * @param array{start: int, end: int} $b */ private static function intersects(array $a, array $b): bool { return $a['start'] < $b['end'] && $b['start'] < $a['end']; } /** * @param array $severities */ private static function maxSeverity(array $severities): ?string { $max = null; foreach ($severities as $severity) { if ((self::SEVERITY_RANK[$severity] ?? 0) > (self::SEVERITY_RANK[$max] ?? 0)) { $max = $severity; } } return $max; } /** * @param list $ids */ private static function issueOpenTag(array $ids, ?string $severity): string { return ''; } /** * 没有位置的 issue:句尾一个三角叹号图标,颜色取最重的 severity。 * * @param list $ids */ private static function issueMark(array $ids, ?string $severity): string { return '' .''; } }