Selaa lähdekoodia

feat(api-v13): 阅读页注入 glossary(换成术语)与 issue(高亮 / 句尾图标),处理交叠与按摘录重新定位

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 18 tuntia sitten
vanhempi
sitoutus
9e5c21d664

+ 12 - 6
api-v13/app/Models/Discussion.php

@@ -68,7 +68,7 @@ class Discussion extends Model
     /** 普通边注:content 就是注解正文,没有出处 */
     /** 普通边注:content 就是注解正文,没有出处 */
     public const TYPE_NOTE = 'note';
     public const TYPE_NOTE = 'note';
 
 
-    /** 会被注入阅读页的批注类型 */
+    /** 批注类型:作为边注注入阅读页,没有回复以外的线程属性(开关、severity) */
     public const INJECTED_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE];
     public const INJECTED_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE];
 
 
     /** 术语讨论:针对句子里某个词的译法提问或建议,带锚点、只能挂在句子上 */
     /** 术语讨论:针对句子里某个词的译法提问或建议,带锚点、只能挂在句子上 */
@@ -80,6 +80,12 @@ class Discussion extends Model
     /** 可以带锚点(pos_* / quote_*)、定位到句子文本某一段的类型:批注 + issue / glossary */
     /** 可以带锚点(pos_* / quote_*)、定位到句子文本某一段的类型:批注 + issue / glossary */
     public const ANCHORED_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, 'issue', self::TYPE_GLOSSARY];
     public const ANCHORED_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, 'issue', self::TYPE_GLOSSARY];
 
 
+    /**
+     * 会被注入阅读页的全部类型({@see PaliContentService::injectAnnotationNotes()}):
+     * 批注插边注,glossary 换成术语,issue 高亮。issue / glossary 只注入未关闭的。
+     */
+    public const READING_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, 'issue', self::TYPE_GLOSSARY];
+
     /** 只能挂在句子上的类型:批注 + glossary */
     /** 只能挂在句子上的类型:批注 + glossary */
     public const SENTENCE_ONLY_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, self::TYPE_GLOSSARY];
     public const SENTENCE_ONLY_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, self::TYPE_GLOSSARY];
 
 
@@ -154,15 +160,15 @@ class Discussion extends Model
     }
     }
 
 
     /**
     /**
-     * 这两类批注会被注入到所挂句子的阅读页里(PaliContentService::
-     * injectAnnotationNotes),阅读页按 (book, para, channel) 缓存,所以增改删它们
-     * 都要清掉那一段的缓存。改之前是、改之后不是(或反过来)的也要清。
+     * 这几类会被注入到所挂句子的阅读页里({@see self::READING_TYPES}),阅读页按
+     * (book, para, channel) 缓存,所以增改删它们(含关闭 / 重开)都要清掉那一段的缓存。
+     * 改之前是、改之后不是(或反过来)的也要清。
      */
      */
     protected static function booted(): void
     protected static function booted(): void
     {
     {
         $forget = function (Discussion $discussion) {
         $forget = function (Discussion $discussion) {
-            $wasInjected = in_array($discussion->getOriginal('type'), self::INJECTED_TYPES, true);
-            if (! in_array($discussion->type, self::INJECTED_TYPES, true) && ! $wasInjected) {
+            $wasInjected = in_array($discussion->getOriginal('type'), self::READING_TYPES, true);
+            if (! in_array($discussion->type, self::READING_TYPES, true) && ! $wasInjected) {
                 return;
                 return;
             }
             }
             if ($discussion->res_type !== 'sentence' || empty($discussion->res_id)) {
             if ($discussion->res_type !== 'sentence' || empty($discussion->res_id)) {

+ 357 - 0
api-v13/app/Services/AnnotationLayout.php

@@ -0,0 +1,357 @@
+<?php
+
+namespace App\Services;
+
+/**
+ * 阅读页批注排版:把锚定在一句译文上的几种批注一次性排进句子的原始 content(markdown)。
+ *
+ * 纯计算,不查库、不渲染——输入 content 与已解析好的批注,输出改写后的 content。
+ * 调用方是 {@see PaliContentService::injectAnnotationNotes()},改写后的 content 再交给 MdRender。
+ *
+ * 三种东西,排法各不相同:
+ * - glossary:区间 [start, end) 的原文换成 `[[title#原文]]`,MdRender 渲染成术语;
+ * - issue:区间外包一层 `<span class="anno-issue …" data-discussion-ids="…">`(高亮,点击看内容);
+ *   没有位置的 issue 合成一个三角叹号图标,放在句尾;
+ * - note / commentary:在插入点插入调用方预先渲染好的 `{{note|…}}` 模板。
+ *
+ * 交叠的处理(核心约束:任何时候都不能切断一个标记):
+ * - 受保护区间:content 里本来就有的标记(`[[…]]`、`{{…}}`、`` `…` ``、`**…**`、HTML 标签),
+ *   谁都不能切断;
+ * - glossary 碰到受保护区间就放弃;两个 glossary 交叠时,包含关系留外层,交叉关系留靠前的,
+ *   另一条放弃(`[[…]]` 不能嵌套,也不能交叉);
+ * - issue 与 glossary / 受保护区间部分交叠时,把 issue 扩展到完整包住对方;
+ *   issue 之间交叠或包含就合并成一个 span,id 取并集、severity 取最重的;
+ *   扩展可能引出新的交叠,反复处理直到稳定。仅仅首尾相接的不合并;
+ * - note 的插入点落在 glossary 或受保护区间内部时,挪到该区间的末尾;落在 issue 内部则原地插入
+ *   (行内元素嵌套是合法的);
+ * - 同一位置上的输出顺序:先收尾的 issue `</span>`,再 note,再开头的 issue `<span>`——
+ *   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<array{id: string, start: int, end: int, title: string}>  $glossaries  已定位的术语
+     * @param  list<array{id: string, start: int|null, end: int|null, severity: string|null}>  $issues  start/end 为 null 表示没有位置
+     * @param  list<array{id: string, at: int, order: array<int, int>, html: string}>  $notes  at 是插入点,order 是同一插入点上的先后
+     * @param  bool  $withIssues  是否输出 issue 的高亮与图标(只有 html 格式需要)
+     * @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}>}
+     *
+     * 返回值里 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 .= '</span>';
+            }
+            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<array{start: int, end: int}>
+     */
+    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<array{start: int, end: int, ids: list<string>, severity: string|null}>  $spans
+     * @param  list<array{start: int, end: int}>  $blockers
+     * @return list<array{start: int, end: int, ids: list<string>, 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<int, string|null>  $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<string>  $ids
+     */
+    private static function issueOpenTag(array $ids, ?string $severity): string
+    {
+        return '<span class="anno-issue anno-issue-'.($severity ?? 'none').'"'
+            .' data-discussion-ids="'.e(implode(',', $ids)).'"'
+            .' data-severity="'.e($severity ?? '').'">';
+    }
+
+    /**
+     * 没有位置的 issue:句尾一个三角叹号图标,颜色取最重的 severity。
+     *
+     * @param  list<string>  $ids
+     */
+    private static function issueMark(array $ids, ?string $severity): string
+    {
+        return '<span class="anno-issue-mark anno-issue-'.($severity ?? 'none').'"'
+            .' data-discussion-ids="'.e(implode(',', $ids)).'"'
+            .' data-severity="'.e($severity ?? '').'" role="button" tabindex="0">'
+            .'<i class="ti ti-alert-triangle"></i></span>';
+    }
+}

+ 129 - 102
api-v13/app/Services/PaliContentService.php

@@ -743,138 +743,165 @@ class PaliContentService
     }
     }
 
 
     /**
     /**
-     * 段落批注内嵌:把锚定到该句子的批注渲染成 {{note}} 模板,按位置插入句子内容。
+     * 段落批注内嵌:把锚定到该句子的批注排进句子 content,再交给 MdRender 渲染。
      *
      *
-     * 两种批注一起注入,除出处外处理完全一样:
-     * - `type='commentary'`(注释书对应):content 是义注 / 复注的句子模板
-     *   `{{book-para-start-end}}`,渲染成这几句的译文,并追加 <cite> 作为跳转锚点;
-     * - `type='note'`(普通边注):content 就是注解正文(markdown),没有出处,不出 <cite>。
+     * 四种批注,显示方式不同(排版规则与交叠处理见 {@see AnnotationLayout}):
+     * - `commentary`(注释书对应):content 是义注 / 复注的句子模板 `{{book-para-start-end}}`,
+     *   渲染成这几句的译文,作为 {{note}} 边注插在锚点末尾,并追加 <cite> 跳转出处;
+     * - `note`(普通边注):content 就是注解正文(markdown),同样插成 {{note}},没有出处;
+     * - `glossary`(术语讨论):锚点处的原文换成 `[[title#原文]]`,渲染成术语(title 是词头);
+     * - `issue`:锚点处的原文包一层高亮 span,带 discussion id,阅读器点击看内容;
+     *   没有位置的合成一个三角叹号图标放在句尾。只在 html 格式输出。
+     * issue / glossary 只注入未关闭的;批注没有开关状态。
      *
      *
-     * 同一句可能有多条批注,按 pos_end 倒序插入 —— 先插靠后的,避免先插入的
-     * 模板改变后面位置的字符偏移(见 docs/reading-annotations.md §4)。
+     * 位置按 pos_* 定位,与 quote_exact 对不上(正文改过)时按摘录重新定位。
+     *
+     * @return array{content: string, notes: list<array{fnId: string, noteHtml: string, citeHtml: string}>}
      */
      */
-    protected function injectAnnotationNotes(Sentence $row, string $channelType): array
+    protected function injectAnnotationNotes(Sentence $row, string $channelType, string $format = 'html'): array
     {
     {
         $content = (string) $row->content;
         $content = (string) $row->content;
         $result = ['content' => $content, 'notes' => []];
         $result = ['content' => $content, 'notes' => []];
-        $notes = Discussion::where('res_type', 'sentence')
+        $records = Discussion::where('res_type', 'sentence')
             ->where('res_id', $row->uid)
             ->where('res_id', $row->uid)
-            ->whereIn('type', Discussion::INJECTED_TYPES)
-            // 只注入批注本身;批注下的回复(v3 起允许回复,类型跟随批注)不进阅读页
+            ->whereIn('type', Discussion::READING_TYPES)
+            // 只注入话题本身;回复(类型跟随话题)不进阅读页
             ->whereNull('parent')
             ->whereNull('parent')
+            ->where(fn ($q) => $q->whereIn('type', Discussion::INJECTED_TYPES)->orWhere('status', 'active'))
+            ->orderBy('created_at')
             ->get();
             ->get();
 
 
-        if ($notes->isEmpty()) {
+        if ($records->isEmpty()) {
             return $result;
             return $result;
         }
         }
 
 
-        $sid = "{$row->book_id}-{$row->paragraph}-{$row->word_start}-{$row->word_end}";
         $len = mb_strlen($content, 'UTF-8');
         $len = mb_strlen($content, 'UTF-8');
-        // 插入顺序:按插入点倒序(先插靠后的,免得前面的插入改变后面的偏移);
-        // 插入点相同的,按义注原文的先后倒序——同一位置后插入的排在前面,
-        // 倒序插入后读起来就是义注原文顺序。插入点与下面的越界处理口径一致
-        // (null / 越界都算句尾)。义注坐标取 content 第一句的 book-para-start;
-        // 普通 note 没有坐标,取 0,同一插入点上读起来排在义注对应之前。
-        $notes = $notes->sort(function ($a, $b) use ($len) {
-            $key = function ($note) use ($len) {
-                $pos = $note->pos_end;
-                if ($pos === null || $pos < 0 || $pos > $len) {
-                    $pos = $len;
+        $locate = fn (Discussion $d) => AnnotationLayout::locate(
+            $content, $d->pos_start, $d->pos_end, $d->quote_exact, $d->quote_prefix, $d->quote_suffix
+        );
+        $glossaries = [];
+        $issues = [];
+        $notes = [];
+        $footnotes = [];
+        foreach ($records as $record) {
+            if ($record->type === Discussion::TYPE_GLOSSARY) {
+                $range = $locate($record);
+                if ($range !== null) {
+                    $glossaries[] = ['id' => $record->id, 'start' => $range[0], 'end' => $range[1], 'title' => (string) $record->title];
                 }
                 }
-                preg_match('/\{\{(\d+)-(\d+)-(\d+)-\d+\}\}/', (string) $note->content, $m);
 
 
-                return [$pos, (int) ($m[1] ?? 0), (int) ($m[2] ?? 0), (int) ($m[3] ?? 0)];
-            };
-
-            return $key($b) <=> $key($a);
-        })->values();
-        $collected = [];
-        foreach ($notes as $note) {
-            $noteContent = trim((string) $note->content);
-            if ($noteContent === '') {
                 continue;
                 continue;
             }
             }
-            $sents = [];
-            if ($note->type === Discussion::TYPE_COMMENTARY) {
-                // content 是一个或多个义注句子模板 {{book-para-start-end}}(一个词可能由义注
-                // 多句解释,按顺序并列,如 {{135-404-50-54}}{{135-404-55-65}})。
-                // 不是这种格式的记录不认,跳过——不插角标,也不进脚注列表。
-                if (! preg_match('/^(?:\{\{\d+-\d+-\d+-\d+\}\}\s*)+$/', $noteContent)) {
-                    continue;
-                }
-                preg_match_all('/\{\{(\d+)-(\d+)-(\d+)-(\d+)\}\}/', $noteContent, $sents, PREG_SET_ORDER);
-                // 义注正文只需译文(不要巴利原文):把每个裸句模板 {{book-para-start-end}}
-                // 转成 {{sent|id=…|text=translation}},让 sent 模板只输出 translation。
-                // 句子可能有很多条,只渲染前 MAX_COMMENTARY_SENTENCES 句;
-                // 跳转锚点仍指向第一句,点进去能看到完整义注。
-                $source = implode(' ', array_map(
-                    fn ($s) => '{{sent|id='.$s[1].'-'.$s[2].'-'.$s[3].'-'.$s[4].'|text=translation}}',
-                    array_slice($sents, 0, self::MAX_COMMENTARY_SENTENCES)
-                ));
-            } else {
-                // 普通边注:content 就是注解正文,照原样渲染。
-                $source = $noteContent;
-            }
-            // 边注实际内容:先用 MdRender 渲染出来,再放进 {{note|text=…}} ——
-            // 直接嵌套 {{…}} 会被 wiki2xml 的平铺替换破坏。
-            // 用 text 格式渲染:避免「1.」被 markdown 解释成有序列表,
-            // 产生 <ol></p></p> 这类坏 HTML 把 sidenote 的闭合结构破坏、吞掉后续正文。
-            $noteHtml = MdRender::render(
-                $source,
-                [$row->channel_uid],
-                null,
-                'read',
-                $channelType,
-                'markdown',
-                'text'
-            );
-            // 义注被截断时补省略号,提示后面还有;想看全文点 <cite> 跳过去。
-            if (count($sents) > self::MAX_COMMENTARY_SENTENCES) {
-                $noteHtml = rtrim($noteHtml).'...';
+            if ($record->type === 'issue') {
+                $range = $locate($record);
+                $issues[] = ['id' => $record->id, 'start' => $range[0] ?? null, 'end' => $range[1] ?? null, 'severity' => $record->severity];
+
+                continue;
             }
             }
-            $pos = $note->pos_end;
-            if ($pos === null || $pos < 0 || $pos > $len) {
-                $pos = $len;
+            $note = $this->renderAnnotationNote($record, $row, $channelType);
+            if ($note === null) {
+                continue;
             }
             }
-            // 出处只有 commentary 有。跳转目标(义注书-段-起-止)同时挂在 <cite> 和角标
-            // <label> 上,前者用于「点链接跳页」,后者用于平板双栏「点角标跨栏高亮」。
-            // 多句时跳转到第一句(义注对这个词的解释从那里开始)。
-            $citeHtml = '';
-            $citeParam = '';
-            if ($sents) {
-                $m = $sents[0];
-                $target = ' data-book="'.$m[1].'" data-para="'.$m[2].'" data-start="'.$m[3].'" data-end="'.$m[4].'"';
-                $citeHtml = '<cite class="anno-jump"'.$target.'>义注</cite>';
-                $citeParam = '|cite=义注'
-                    .'|citelink='.$m[1].'-'.$m[2].'-'.$m[3].'-'.$m[4];
+            // 插入点:锚点末尾;定位不到时沿用 pos_end,null / 越界都算句尾
+            $range = $locate($record);
+            $at = $range[1] ?? $record->pos_end;
+            if ($at === null || $at < 0 || $at > $len) {
+                $at = $len;
             }
             }
-            // 用 {{note}} 模板渲染 tufte sidenote(label + input + span.sidenote),
-            // 复用 render_note() 的结构,不再手拼 sidenote HTML。
-            // text 传已预渲染的纯文本(嵌套 {{…}} 会被 wiki2xml 平铺替换破坏)。
-            $noteTplInline = '{{note|text='.$noteHtml.$citeParam.'}}';
-            $content = mb_substr($content, 0, $pos, 'UTF-8')
-                .$noteTplInline
-                .mb_substr($content, $pos, null, 'UTF-8');
-            // 收集脚注列表条目(倒序插入,最后反转回阅读顺序)
-            $collected[] = [
-                'fnId' => 'fn-'.$note->id,
-                'noteHtml' => $noteHtml,
-                'citeHtml' => $citeHtml,
+            $notes[] = ['id' => $record->id, 'at' => $at, 'order' => $note['order'], 'html' => $note['template']];
+            $footnotes[$record->id] = [
+                'fnId' => 'fn-'.$record->id,
+                'noteHtml' => $note['noteHtml'],
+                'citeHtml' => $note['citeHtml'],
             ];
             ];
         }
         }
 
 
-        $result['notes'] = array_reverse($collected);
-        $result['content'] = $content;
+        $layout = AnnotationLayout::apply($content, $glossaries, $issues, $notes, $format === 'html');
+        $result['content'] = $layout['content'];
+        $result['notes'] = array_map(fn ($id) => $footnotes[$id], $layout['notes']);
 
 
         Log::info('reading-annotations: inject', [
         Log::info('reading-annotations: inject', [
-            'sid' => $sid,
-            'count' => $notes->count(),
-            'content' => $content,
+            'sid' => "{$row->book_id}-{$row->paragraph}-{$row->word_start}-{$row->word_end}",
+            'count' => $records->count(),
+            'content' => $result['content'],
         ]);
         ]);
 
 
         return $result;
         return $result;
     }
     }
 
 
+    /**
+     * 把一条批注(commentary / note)渲染成要插入正文的 {{note}} 模板与段后脚注条目。
+     * 不合格的(正文为空、commentary 不是句子模板)返回 null,不插角标也不进脚注列表。
+     *
+     * @return array{template: string, noteHtml: string, citeHtml: string, order: array<int, int>}|null
+     */
+    protected function renderAnnotationNote(Discussion $note, Sentence $row, string $channelType): ?array
+    {
+        $noteContent = trim((string) $note->content);
+        if ($noteContent === '') {
+            return null;
+        }
+        $sents = [];
+        if ($note->type === Discussion::TYPE_COMMENTARY) {
+            // content 是一个或多个义注句子模板 {{book-para-start-end}}(一个词可能由义注
+            // 多句解释,按顺序并列,如 {{135-404-50-54}}{{135-404-55-65}})。
+            if (! preg_match(Discussion::COMMENTARY_PATTERN, $noteContent)) {
+                return null;
+            }
+            preg_match_all('/\{\{(\d+)-(\d+)-(\d+)-(\d+)\}\}/', $noteContent, $sents, PREG_SET_ORDER);
+            // 义注正文只需译文(不要巴利原文):把每个裸句模板 {{book-para-start-end}}
+            // 转成 {{sent|id=…|text=translation}},让 sent 模板只输出 translation。
+            // 句子可能有很多条,只渲染前 MAX_COMMENTARY_SENTENCES 句;
+            // 跳转锚点仍指向第一句,点进去能看到完整义注。
+            $source = implode(' ', array_map(
+                fn ($s) => '{{sent|id='.$s[1].'-'.$s[2].'-'.$s[3].'-'.$s[4].'|text=translation}}',
+                array_slice($sents, 0, self::MAX_COMMENTARY_SENTENCES)
+            ));
+        } else {
+            // 普通边注:content 就是注解正文,照原样渲染。
+            $source = $noteContent;
+        }
+        // 边注实际内容:先用 MdRender 渲染出来,再放进 {{note|text=…}} ——
+        // 直接嵌套 {{…}} 会被 wiki2xml 的平铺替换破坏。
+        // 用 text 格式渲染:避免「1.」被 markdown 解释成有序列表,
+        // 产生 <ol></p></p> 这类坏 HTML 把 sidenote 的闭合结构破坏、吞掉后续正文。
+        $noteHtml = MdRender::render(
+            $source,
+            [$row->channel_uid],
+            null,
+            'read',
+            $channelType,
+            'markdown',
+            'text'
+        );
+        // 义注被截断时补省略号,提示后面还有;想看全文点 <cite> 跳过去。
+        if (count($sents) > self::MAX_COMMENTARY_SENTENCES) {
+            $noteHtml = rtrim($noteHtml).'...';
+        }
+        // 出处只有 commentary 有。跳转目标(义注书-段-起-止)同时挂在 <cite> 和角标
+        // <label> 上,前者用于「点链接跳页」,后者用于平板双栏「点角标跨栏高亮」。
+        // 多句时跳转到第一句(义注对这个词的解释从那里开始)。
+        $citeHtml = '';
+        $citeParam = '';
+        if ($sents) {
+            $m = $sents[0];
+            $target = ' data-book="'.$m[1].'" data-para="'.$m[2].'" data-start="'.$m[3].'" data-end="'.$m[4].'"';
+            $citeHtml = '<cite class="anno-jump"'.$target.'>义注</cite>';
+            $citeParam = '|cite=义注'
+                .'|citelink='.$m[1].'-'.$m[2].'-'.$m[3].'-'.$m[4];
+        }
+
+        return [
+            // 用 {{note}} 模板渲染 tufte sidenote(label + input + span.sidenote),复用 render_note()。
+            // text 传已预渲染的纯文本(嵌套 {{…}} 会被 wiki2xml 平铺替换破坏)。
+            'template' => '{{note|text='.$noteHtml.$citeParam.'}}',
+            'noteHtml' => $noteHtml,
+            'citeHtml' => $citeHtml,
+            // 同一插入点上的先后:按义注原文的先后;普通 note 没有坐标,取 0,排在义注对应之前
+            'order' => $sents ? [(int) $sents[0][1], (int) $sents[0][2], (int) $sents[0][3]] : [0, 0, 0],
+        ];
+    }
+
     /**
     /**
      * 段落脚注列表:把本段的注释条目逐条列在段后,每条默认收起为可配置行数(见阅读器 CSS)。
      * 段落脚注列表:把本段的注释条目逐条列在段后,每条默认收起为可配置行数(见阅读器 CSS)。
      */
      */
@@ -924,7 +951,7 @@ class PaliContentService
 
 
         $allNotes = [];
         $allNotes = [];
         foreach ($records as $row) {
         foreach ($records as $row) {
-            $injected = $this->injectAnnotationNotes($row, $channelType);
+            $injected = $this->injectAnnotationNotes($row, $channelType, $format);
             $content = $injected['content'];
             $content = $injected['content'];
             $allNotes = array_merge($allNotes, $injected['notes']);
             $allNotes = array_merge($allNotes, $injected['notes']);
             $html = MdRender::render(
             $html = MdRender::render(