AnnotationLayout.php 17 KB

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