MarkerParser.php 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. <?php
  2. /**
  3. * wbw_templates 里 .ctl. 锚点标记的解析器。
  4. *
  5. * 这些标记来自 VRI html 的 <a name="..."> 锚点,是整个 related_paragraphs 的唯一
  6. * 事实来源。旧生成器(cs6_para.csv 那条链路)在这里翻过车——缩写区间被跳过或
  7. * 误解析成六位数废值——所以解析规则集中在这一个类里,导出器和检测器共用同一份,
  8. * 不允许各写一遍。
  9. *
  10. * 形态实测(type='.ctl.' and style='#a#',330267 行):
  11. * pagemark 167647 / paraN 116921 / paraN_book 39998 / paraN-M 3611
  12. * / 书名·章标记 1096 / paraN-M_book 989 / 链式 4 / 截断 1
  13. */
  14. class MarkerParser
  15. {
  16. /** 解析结果超过此值一律视为废值,拒绝输出。实测合法上界 3183。 */
  17. public const CS_MAX = 5000;
  18. public const KIND_SINGLE = 'single'; // para4
  19. public const KIND_INTERVAL = 'interval'; // para151-152 / para292-3 / para292-3-6
  20. public const KIND_BOOK_TAG = 'book_tag'; // dn1
  21. public const KIND_CHAPTER = 'chapter'; // dn1_9 / an5_1_1 —— 章标记或义注标记,同形歧义
  22. public const KIND_PAGE = 'page'; // P1.0023
  23. public const KIND_TRUNCATED = 'truncated'; // para179-
  24. public const KIND_UNKNOWN = 'unknown';
  25. /**
  26. * @return array{
  27. * kind: string,
  28. * cs: list<int>, 解析出的 cs 值(已展开区间)
  29. * book_name: ?string, 标记自带的书名后缀
  30. * number: ?int, chapter 形态的首级编号
  31. * numbers: ?list<int>, chapter 形态的完整编号路径(an5_1_1 → [1,1])
  32. * repaired: ?string, 规则修补过的标记,须记 issue 复核
  33. * raw: string,
  34. * error: ?string, 非空表示拒绝输出,调用方须记 issue
  35. * }
  36. */
  37. public static function parse(string $word): array
  38. {
  39. $base = [
  40. 'kind' => self::KIND_UNKNOWN,
  41. 'cs' => [],
  42. 'book_name' => null,
  43. 'number' => null,
  44. 'numbers' => null,
  45. 'repaired' => null,
  46. 'raw' => $word,
  47. 'error' => null,
  48. ];
  49. // 页码/卷标 P/M/V/T/O × 0-5:本轮一律忽略(§6.7)
  50. if (preg_match('/^[A-Z][0-9]/', $word)) {
  51. return ['kind' => self::KIND_PAGE] + $base;
  52. }
  53. if (! str_starts_with($word, 'para')) {
  54. // 纯书名 dn1(书上下文事件)或 dn1_9(章标记 / 义注标记)
  55. // 章标记,可带多级编号路径:dn1_9 是一级,an5_1_1 是两级(实测 13 本 247 行,
  56. // 义注书 100-102 与 mūla 书 82-92 共用同一套编号,是跨文件章界对齐的抓手)。
  57. if (preg_match('/^([a-z][a-z0-9]*)((?:_[0-9]+)+)$/', $word, $m)) {
  58. $numbers = array_values(array_map('intval', array_filter(explode('_', $m[2]), 'strlen')));
  59. return [
  60. 'kind' => self::KIND_CHAPTER,
  61. 'book_name' => $m[1],
  62. 'number' => $numbers[0],
  63. 'numbers' => $numbers,
  64. ] + $base;
  65. }
  66. if (preg_match('/^[a-z][a-z0-9]*$/', $word)) {
  67. return ['kind' => self::KIND_BOOK_TAG, 'book_name' => $word] + $base;
  68. }
  69. return $base;
  70. }
  71. $body = substr($word, 4);
  72. // 尾部 _book 后缀。区间与单值都可能带,如 para1008-9_sn5(实测 989 行)。
  73. $bookName = null;
  74. if (preg_match('/^(.*?)_([a-z][a-z0-9_]*)$/', $body, $m)) {
  75. $body = $m[1];
  76. $bookName = $m[2];
  77. }
  78. if ($body === '' || ! preg_match('/^[0-9]/', $body)) {
  79. return ['error' => '标记主体不是数字'] + $base;
  80. }
  81. // para179- :区间右端缺失,全库仅 book 11 para 1577 一处,留给人工。
  82. if (str_ends_with($body, '-')) {
  83. return ['kind' => self::KIND_TRUNCATED, 'book_name' => $bookName, 'error' => '区间右端缺失'] + $base;
  84. }
  85. $parts = explode('-', $body);
  86. foreach ($parts as $part) {
  87. if (! preg_match('/^[0-9]+$/', $part)) {
  88. return ['error' => "区间片段非数字:$part"] + $base;
  89. }
  90. }
  91. if (count($parts) === 1) {
  92. $cs = (int) $parts[0];
  93. if ($cs > self::CS_MAX) {
  94. return ['book_name' => $bookName, 'error' => "cs 超值域:$cs"] + $base;
  95. }
  96. return ['kind' => self::KIND_SINGLE, 'cs' => [$cs], 'book_name' => $bookName] + $base;
  97. }
  98. // 链式 para292-3-6:逐段以前一个完整值补齐,取首尾展开(实测仅 4 行)。
  99. $start = (int) $parts[0];
  100. $current = $start;
  101. for ($i = 1; $i < count($parts); $i++) {
  102. $current = self::completePrefix($current, $parts[$i]);
  103. if ($current === null) {
  104. return ['book_name' => $bookName, 'error' => "区间右端无法补齐:$body"] + $base;
  105. }
  106. }
  107. $end = $current;
  108. $repaired = null;
  109. if ($end < $start) {
  110. // 倒序区间是 VRI 源里的高位笔误,低位是对的:实测 para504-407 前邻 502-503
  111. // 后邻 508(真值 504-507)、para706-608 前邻 703-705 后邻 709(真值 706-708)。
  112. // 丢掉右端高位、只用低位重新补齐即可还原。全库仅 3 行,一律记 issue 人工复核。
  113. $tail = end($parts);
  114. $end = self::repairReversedEnd($start, $tail);
  115. if ($end === null) {
  116. return ['book_name' => $bookName, 'error' => "区间倒序且无法修补:$body"] + $base;
  117. }
  118. $repaired = "倒序区间按低位补齐:$body → $start-$end";
  119. }
  120. if ($end > self::CS_MAX || $start > self::CS_MAX) {
  121. return ['book_name' => $bookName, 'error' => "cs 超值域:$start-$end"] + $base;
  122. }
  123. if ($end - $start > 2000) {
  124. return ['book_name' => $bookName, 'error' => "区间过宽:$start-$end"] + $base;
  125. }
  126. return [
  127. 'kind' => self::KIND_INTERVAL,
  128. 'cs' => range($start, $end),
  129. 'book_name' => $bookName,
  130. 'repaired' => $repaired,
  131. ] + $base;
  132. }
  133. /**
  134. * VRI 缩写区间语法:右端只写变化的低位,用左端补齐前缀。
  135. * 292-3 → 293 (补 "29")
  136. * 1008-9 → 1009 (补 "100")
  137. * 15-8 → 18 (补 "1")
  138. * 298-3 → 303 (补出来 293 < 298,进位加 10^1)
  139. * 右端位数不少于左端时不是缩写,原样返回。
  140. */
  141. public static function completePrefix(int $left, string $rightDigits): ?int
  142. {
  143. $leftDigits = (string) $left;
  144. $rightLen = strlen($rightDigits);
  145. if ($rightLen >= strlen($leftDigits)) {
  146. return (int) $rightDigits;
  147. }
  148. $prefix = substr($leftDigits, 0, strlen($leftDigits) - $rightLen);
  149. $completed = (int) ($prefix.$rightDigits);
  150. if ($completed <= $left) {
  151. // 低位回绕,向上进一位:298-3 的 3 指的是 303 不是 293。
  152. $completed += 10 ** $rightLen;
  153. }
  154. return $completed > $left ? $completed : null;
  155. }
  156. /**
  157. * 倒序区间的还原:逐步丢弃右端高位,只保留低位再按前缀补齐,取第一个大于左端
  158. * 且区间宽度合理的结果。
  159. * 504-407 → 低两位 "07" 补成 507
  160. * 706-608 → 低两位 "08" 补成 708
  161. */
  162. public static function repairReversedEnd(int $start, string $rightDigits): ?int
  163. {
  164. for ($keep = strlen($rightDigits) - 1; $keep >= 1; $keep--) {
  165. $candidate = self::completePrefix($start, substr($rightDigits, -$keep));
  166. if ($candidate !== null && $candidate > $start && $candidate - $start <= 20) {
  167. return $candidate;
  168. }
  169. }
  170. return null;
  171. }
  172. }