`(高亮,点击看内容);
* 没有位置的 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 ''
.'';
}
}