AnnotationLayout.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. <?php
  2. namespace App\Services;
  3. /**
  4. * 阅读页批注排版:把锚定在一句译文上的几种批注一次性排进句子的原始 content(markdown)。
  5. *
  6. * 纯计算,不查库、不渲染——输入 content 与已解析好的批注,输出改写后的 content。
  7. * 调用方是 {@see PaliContentService::injectAnnotationNotes()},改写后的 content 再交给 MdRender。
  8. *
  9. * 三种东西,排法各不相同:
  10. * - glossary:区间 [start, end) 的原文换成 `[[title#原文]]`,MdRender 渲染成术语;
  11. * - issue:区间外包一层 `<span class="anno-issue …" data-discussion-ids="…">`(高亮,点击看内容);
  12. * 没有位置的 issue 合成一个三角叹号图标,放在句尾;
  13. * - note / commentary:在插入点插入调用方预先渲染好的 `{{note|…}}` 模板。
  14. *
  15. * 交叠的处理(核心约束:任何时候都不能切断一个标记):
  16. * - 受保护区间:content 里本来就有的标记(`[[…]]`、`{{…}}`、`` `…` ``、`**…**`、HTML 标签),
  17. * 谁都不能切断;
  18. * - glossary 碰到受保护区间就放弃;两个 glossary 交叠时,包含关系留外层,交叉关系留靠前的,
  19. * 另一条放弃(`[[…]]` 不能嵌套,也不能交叉);
  20. * - issue 与 glossary / 受保护区间部分交叠时,把 issue 扩展到完整包住对方;
  21. * issue 之间交叠或包含就合并成一个 span,id 取并集、severity 取最重的;
  22. * 扩展可能引出新的交叠,反复处理直到稳定。仅仅首尾相接的不合并;
  23. * - note 的插入点落在 glossary 或受保护区间内部时,挪到该区间的末尾;落在 issue 内部则原地插入
  24. * (行内元素嵌套是合法的);
  25. * - 同一位置上的输出顺序:先收尾的 issue `</span>`,再 note,再开头的 issue `<span>`——
  26. * note 锚定的是位置之前的文字,应当跟在结束的高亮之后、开始的高亮之前。
  27. */
  28. class AnnotationLayout
  29. {
  30. /** 严重程度由轻到重;合并时取最重的,没有 severity 的最轻 */
  31. private const SEVERITY_RANK = ['suggestion' => 1, 'warning' => 2, 'error' => 3];
  32. /** glossary 的原文或词头里不能有这些字符,否则 `[[title#原文]]` 会被拆坏 */
  33. private const GLOSSARY_FORBIDDEN = '/[\[\]{}|#\r\n]/u';
  34. /**
  35. * 受保护区间:已有的模板、术语、行内注释、粗体与 HTML 标签。
  36. */
  37. private const PROTECTED_PATTERN = '/\[\[.*?\]\]|\{\{.*?\}\}|`[^`]*`|\*\*.+?\*\*|<[^<>]+>/us';
  38. /**
  39. * 按选择器在 content 里定位一段文字,返回 [start, end)(字符偏移),定位不到返回 null。
  40. *
  41. * 先信位置:pos 合法且(没有摘录或摘录对得上)就用 pos。对不上说明正文改过,按摘录重新找:
  42. * 有前后缀就优先选前后缀也吻合的那一处,同分取离原位置最近的。
  43. *
  44. * @return array{0: int, 1: int}|null
  45. */
  46. public static function locate(
  47. string $content,
  48. ?int $start,
  49. ?int $end,
  50. ?string $exact = null,
  51. ?string $prefix = null,
  52. ?string $suffix = null,
  53. ): ?array {
  54. $len = mb_strlen($content, 'UTF-8');
  55. $exact = (string) $exact;
  56. $posValid = $start !== null && $end !== null && $start >= 0 && $start < $end && $end <= $len;
  57. if ($posValid && ($exact === '' || mb_substr($content, $start, $end - $start, 'UTF-8') === $exact)) {
  58. return [$start, $end];
  59. }
  60. if ($exact === '') {
  61. return null;
  62. }
  63. $exactLen = mb_strlen($exact, 'UTF-8');
  64. $prefix = (string) $prefix;
  65. $suffix = (string) $suffix;
  66. $best = null;
  67. $bestScore = null;
  68. $offset = 0;
  69. while (($found = mb_strpos($content, $exact, $offset, 'UTF-8')) !== false) {
  70. // 前缀:紧挨在前面的文字是前缀的结尾(句首附近可能不够长,比能比的那部分)
  71. $n = min($found, mb_strlen($prefix, 'UTF-8'));
  72. $before = mb_substr($content, $found - $n, $n, 'UTF-8');
  73. $matchPrefix = $n > 0 && str_ends_with($prefix, $before);
  74. // 后缀:紧跟在后面的文字是后缀的开头
  75. $after = mb_substr($content, $found + $exactLen, mb_strlen($suffix, 'UTF-8'), 'UTF-8');
  76. $matchSuffix = $after !== '' && str_starts_with($suffix, $after);
  77. $distance = $start === null ? $found : abs($found - $start);
  78. // 分数:前后缀吻合得越多越好,其次离原位置越近越好
  79. $score = [(int) $matchPrefix + (int) $matchSuffix, -$distance];
  80. if ($bestScore === null || $score > $bestScore) {
  81. $best = $found;
  82. $bestScore = $score;
  83. }
  84. $offset = $found + 1;
  85. }
  86. return $best === null ? null : [$best, $best + $exactLen];
  87. }
  88. /**
  89. * 排版。
  90. *
  91. * @param list<array{id: string, start: int, end: int, title: string}> $glossaries 已定位的术语
  92. * @param list<array{id: string, start: int|null, end: int|null, severity: string|null}> $issues start/end 为 null 表示没有位置
  93. * @param list<array{id: string, at: int, order: array<int, int>, html: string}> $notes at 是插入点,order 是同一插入点上的先后
  94. * @param bool $withIssues 是否输出 issue 的高亮与图标(只有 html 格式需要)
  95. * @return array{content: string, notes: list<string>, glossaries: list<string>, issues: list<array{ids: list<string>, severity: string|null, start: int|null, end: int|null}>}
  96. *
  97. * 返回值里 notes 是 note id 的阅读顺序,glossaries 是实际替换了的术语 id,
  98. * issues 是合并后的高亮(没有位置的合成一条,start / end 为 null)。
  99. */
  100. public static function apply(string $content, array $glossaries, array $issues, array $notes, bool $withIssues = true): array
  101. {
  102. $len = mb_strlen($content, 'UTF-8');
  103. $protected = self::protectedRanges($content);
  104. // ---- glossary:放弃碰到受保护区间的、字符不合法的,然后贪心取不交叠的
  105. $glossaries = array_values(array_filter($glossaries, function (array $g) use ($content, $len, $protected) {
  106. if ($g['start'] < 0 || $g['start'] >= $g['end'] || $g['end'] > $len) {
  107. return false;
  108. }
  109. $text = mb_substr($content, $g['start'], $g['end'] - $g['start'], 'UTF-8');
  110. if (trim($g['title']) === '' || preg_match(self::GLOSSARY_FORBIDDEN, $text.$g['title'])) {
  111. return false;
  112. }
  113. foreach ($protected as $p) {
  114. if (self::intersects($g, $p)) {
  115. return false;
  116. }
  117. }
  118. return true;
  119. }));
  120. // 开头靠前的优先;开头相同的,长的(外层)优先
  121. usort($glossaries, fn ($a, $b) => [$a['start'], $b['end'], $a['id']] <=> [$b['start'], $a['end'], $b['id']]);
  122. $kept = [];
  123. foreach ($glossaries as $g) {
  124. foreach ($kept as $k) {
  125. if (self::intersects($g, $k)) {
  126. continue 2;
  127. }
  128. }
  129. $kept[] = $g;
  130. }
  131. $glossaries = $kept;
  132. // ---- issue:扩展到不切断任何 glossary / 受保护区间,再合并交叠的,直到稳定
  133. $spans = [];
  134. $unanchored = [];
  135. foreach ($issues as $issue) {
  136. $s = $issue['start'];
  137. $e = $issue['end'];
  138. if ($s === null || $e === null || $s < 0 || $s >= $e || $e > $len) {
  139. $unanchored[] = $issue;
  140. continue;
  141. }
  142. $spans[] = ['start' => $s, 'end' => $e, 'ids' => [$issue['id']], 'severity' => $issue['severity']];
  143. }
  144. $spans = self::settleSpans($spans, [...$glossaries, ...$protected]);
  145. // ---- note:插入点不能落在 glossary / 受保护区间内部
  146. $blockers = [...$glossaries, ...$protected];
  147. foreach ($notes as &$note) {
  148. $at = min(max($note['at'], 0), $len);
  149. do {
  150. $moved = false;
  151. foreach ($blockers as $b) {
  152. if ($b['start'] < $at && $at < $b['end']) {
  153. $at = $b['end'];
  154. $moved = true;
  155. }
  156. }
  157. } while ($moved);
  158. $note['at'] = $at;
  159. }
  160. unset($note);
  161. usort($notes, fn ($a, $b) => [$a['at'], ...$a['order']] <=> [$b['at'], ...$b['order']]);
  162. // ---- 按位置从左到右输出
  163. $opens = [];
  164. $closes = [];
  165. if ($withIssues) {
  166. foreach ($spans as $span) {
  167. $opens[$span['start']][] = $span;
  168. $closes[$span['end']][] = $span;
  169. }
  170. }
  171. $notesAt = [];
  172. foreach ($notes as $note) {
  173. $notesAt[$note['at']][] = $note['html'];
  174. }
  175. $glossaryAt = [];
  176. foreach ($glossaries as $g) {
  177. $glossaryAt[$g['start']] = $g;
  178. }
  179. $points = array_unique([0, $len, ...array_keys($opens), ...array_keys($closes), ...array_keys($notesAt), ...array_keys($glossaryAt)]);
  180. sort($points);
  181. $out = '';
  182. $cursor = 0;
  183. foreach ($points as $x) {
  184. if ($x < $cursor) {
  185. continue; // 落在已整段输出的 glossary 内部:构造上不会出现,只是防御
  186. }
  187. $out .= mb_substr($content, $cursor, $x - $cursor, 'UTF-8');
  188. $cursor = $x;
  189. // 合并后 issue 之间互不交叠,同一点上至多一个收尾、一个开头
  190. foreach ($closes[$x] ?? [] as $span) {
  191. $out .= '</span>';
  192. }
  193. foreach ($notesAt[$x] ?? [] as $html) {
  194. $out .= $html;
  195. }
  196. foreach ($opens[$x] ?? [] as $span) {
  197. $out .= self::issueOpenTag($span['ids'], $span['severity']);
  198. }
  199. if (isset($glossaryAt[$x])) {
  200. $g = $glossaryAt[$x];
  201. $text = mb_substr($content, $g['start'], $g['end'] - $g['start'], 'UTF-8');
  202. $out .= '[['.trim($g['title']).'#'.$text.']]';
  203. $cursor = $g['end'];
  204. }
  205. }
  206. $out .= mb_substr($content, $cursor, null, 'UTF-8');
  207. if ($withIssues && $unanchored !== []) {
  208. $out .= self::issueMark(
  209. array_column($unanchored, 'id'),
  210. self::maxSeverity(array_column($unanchored, 'severity')),
  211. );
  212. }
  213. $report = array_map(fn ($s) => [
  214. 'ids' => $s['ids'], 'severity' => $s['severity'], 'start' => $s['start'], 'end' => $s['end'],
  215. ], $spans);
  216. if ($unanchored !== []) {
  217. $report[] = [
  218. 'ids' => array_column($unanchored, 'id'),
  219. 'severity' => self::maxSeverity(array_column($unanchored, 'severity')),
  220. 'start' => null,
  221. 'end' => null,
  222. ];
  223. }
  224. return [
  225. 'content' => $out,
  226. 'notes' => array_column($notes, 'id'),
  227. 'glossaries' => array_column($glossaries, 'id'),
  228. 'issues' => $report,
  229. ];
  230. }
  231. /**
  232. * content 里已有的标记占据的区间(字符偏移)。
  233. *
  234. * @return list<array{start: int, end: int}>
  235. */
  236. public static function protectedRanges(string $content): array
  237. {
  238. preg_match_all(self::PROTECTED_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE);
  239. $ranges = [];
  240. foreach ($matches[0] as [$text, $byteOffset]) {
  241. $start = mb_strlen(substr($content, 0, $byteOffset), 'UTF-8');
  242. $ranges[] = ['start' => $start, 'end' => $start + mb_strlen($text, 'UTF-8')];
  243. }
  244. return $ranges;
  245. }
  246. /**
  247. * 把 issue 区间扩展到不切断任何障碍物,并合并交叠的 issue,反复直到稳定。
  248. *
  249. * @param list<array{start: int, end: int, ids: list<string>, severity: string|null}> $spans
  250. * @param list<array{start: int, end: int}> $blockers
  251. * @return list<array{start: int, end: int, ids: list<string>, severity: string|null}>
  252. */
  253. private static function settleSpans(array $spans, array $blockers): array
  254. {
  255. do {
  256. $changed = false;
  257. // 扩展:与障碍物交叠但没有完整包住它,就把它包进来
  258. foreach ($spans as &$span) {
  259. foreach ($blockers as $b) {
  260. if (self::intersects($span, $b) && ($b['start'] < $span['start'] || $b['end'] > $span['end'])) {
  261. $span['start'] = min($span['start'], $b['start']);
  262. $span['end'] = max($span['end'], $b['end']);
  263. $changed = true;
  264. }
  265. }
  266. }
  267. unset($span);
  268. // 合并:按开头排序后,与上一个交叠(含包含)的并进去
  269. usort($spans, fn ($a, $b) => [$a['start'], $a['end']] <=> [$b['start'], $b['end']]);
  270. $merged = [];
  271. foreach ($spans as $span) {
  272. $last = count($merged) - 1;
  273. if ($last >= 0 && self::intersects($merged[$last], $span)) {
  274. $merged[$last]['end'] = max($merged[$last]['end'], $span['end']);
  275. $merged[$last]['ids'] = [...$merged[$last]['ids'], ...$span['ids']];
  276. $merged[$last]['severity'] = self::maxSeverity([$merged[$last]['severity'], $span['severity']]);
  277. $changed = true;
  278. } else {
  279. $merged[] = $span;
  280. }
  281. }
  282. $spans = $merged;
  283. } while ($changed);
  284. return $spans;
  285. }
  286. /**
  287. * 两个半开区间是否有公共部分(首尾相接不算)。
  288. *
  289. * @param array{start: int, end: int} $a
  290. * @param array{start: int, end: int} $b
  291. */
  292. private static function intersects(array $a, array $b): bool
  293. {
  294. return $a['start'] < $b['end'] && $b['start'] < $a['end'];
  295. }
  296. /**
  297. * @param array<int, string|null> $severities
  298. */
  299. private static function maxSeverity(array $severities): ?string
  300. {
  301. $max = null;
  302. foreach ($severities as $severity) {
  303. if ((self::SEVERITY_RANK[$severity] ?? 0) > (self::SEVERITY_RANK[$max] ?? 0)) {
  304. $max = $severity;
  305. }
  306. }
  307. return $max;
  308. }
  309. /**
  310. * @param list<string> $ids
  311. */
  312. private static function issueOpenTag(array $ids, ?string $severity): string
  313. {
  314. return '<span class="anno-issue anno-issue-'.($severity ?? 'none').'"'
  315. .' data-discussion-ids="'.e(implode(',', $ids)).'"'
  316. .' data-severity="'.e($severity ?? '').'">';
  317. }
  318. /**
  319. * 没有位置的 issue:句尾一个三角叹号图标,颜色取最重的 severity。
  320. *
  321. * @param list<string> $ids
  322. */
  323. private static function issueMark(array $ids, ?string $severity): string
  324. {
  325. return '<span class="anno-issue-mark anno-issue-'.($severity ?? 'none').'"'
  326. .' data-discussion-ids="'.e(implode(',', $ids)).'"'
  327. .' data-severity="'.e($severity ?? '').'" role="button" tabindex="0">'
  328. .'<i class="ti ti-alert-triangle"></i></span>';
  329. }
  330. }