refspy.matcher
Match biblical references in texts, returning strings and Reference objects.
1"""Match biblical references in texts, returning strings and Reference objects.""" 2 3import math 4import re 5from re import Match 6from collections.abc import Generator 7 8from refspy.models.book import Book 9from refspy.models.language import Language 10from refspy.models.range import Range, range, verse_range 11from refspy.models.reference import ( 12 Reference, 13 book_reference, 14 reference, 15) 16from refspy.models.syntax import Syntax 17from refspy.models.verse import verse 18 19from refspy.types.number import Number 20 21from refspy.utils import ( 22 add_space_after_book_number, 23 get_unnumbered_book_aliases, 24 parse_number, 25 normalize_spacing, 26 trim_trailing_period, 27) 28 29 30class Matcher: 31 """ 32 Match: 33 - Opening and closing brackets 34 - So we don't carry book context past the end of parentheses 35 - [PROPOSED] Ends of sentence and end of paragraph. 36 - So we don't carry book context for an undue distance within the text. 37 - References 38 - Book names (incl. substitutions like 'First' for '1': 39 - with no reference: sets context; only returned as a reference if 40 requested with yield_books=True 41 - when book.chapter > 1: 42 - 1:2-3:4 43 - 1:1,2-3 etc 44 - when book.chapter == 1: 45 - 1,2-4 (can be verses or chapters, based on book; must not end with 46 a number that starts a book name) 47 - Stand-alone number references: always have colons or verse markers. 48 They rely on context being provided by a previous reference or book 49 name. 50 - v.1, vv.1-2 51 - 1:2-3:4 52 - 1:1,2-3 etc (must not end with a number that starts a book name) 53 Yield: 54 - a (match_str, reference) tuple for each match. 55 """ 56 57 def __init__( 58 self, 59 books: dict[tuple[Number, Number], Book], 60 book_aliases: dict[str, tuple[Number, Number]], 61 language: Language, 62 syntax: Syntax | None = None, 63 ): 64 self.books = books 65 self.language = language 66 self.syntax = syntax or language.syntax 67 self.book_alias_keys = book_aliases.keys() 68 self.book_aliases = self.expand_book_aliases(book_aliases) 69 70 # Regexes for matching are generated on intiialisation, 71 # using the language object for punctuation. 72 73 self.SPACE = r"\s+" 74 self.OPTIONAL_SPACE = r"\s*" 75 self.END = r"\b" 76 77 self.COMMA = ( 78 "[" + re.escape(self.syntax.match_commas) + "]" + self.OPTIONAL_SPACE 79 ) 80 self.COLON = ( 81 "[" + re.escape(self.syntax.match_colons) + "]" + self.OPTIONAL_SPACE 82 ) 83 self.DASH = "[" + re.escape(self.syntax.match_dashes) + "]" 84 self.NUMBER = r"\d+[a-d]?" 85 86 self.RANGE = f"{self.NUMBER}{self.DASH}{self.NUMBER}" 87 self.LIST = f"(?:{self.RANGE}|{self.NUMBER})(?:{self.COMMA}(?:{self.RANGE}|{self.NUMBER}))*" 88 89 self.RANGE_OR_NUMBER_COMPILED = re.compile(f"({self.RANGE}|{self.NUMBER})") 90 91 self.NUMBER_CAPTURE = re.compile(f"({self.NUMBER})") 92 self.RANGE_CAPTURE = re.compile(f"({self.NUMBER}){self.DASH}({self.NUMBER})") 93 self.CHAPTER_RANGE_CAPTURE = re.compile( 94 f"{self.END}({self.NUMBER}){self.COLON}({self.NUMBER}){self.DASH}" 95 + f"({self.NUMBER}){self.COLON}({self.NUMBER}){self.END}" 96 ) 97 self.CHAPTER_VERSES_CAPTURE = re.compile( 98 f"{self.END}({self.NUMBER}){self.COLON}({self.LIST}){self.END}" 99 ) 100 101 self.brackets_regexp = re.compile(self.build_brackets_regexp()) 102 self.reference_regexp = re.compile(self.build_reference_regexp()) 103 104 def build_reference_regexp(self) -> str: 105 """ 106 Reference matches are quadruples. For the string ... 107 108 "Big Book 1:2-5 (cf. Small Book 34) is more interesting than vv.2:3-6." 109 110 ... We want to know which references are attached to books and which are just numeric: 111 112 ``` 113 Matching string 114 : Book Name 115 : : Book Reference 116 : : : Numeric Reference 117 : : : : 118 ('Big Book 1:2-5', 'Big Book', '1:2-5', None) 119 ('Small Book 34', 'Small Book', '34', None) 120 ( None, None, None, '2:3-6') 121 ``` 122 123 Additionally, we want to minimize bad matches like John Smith, or John 124 B. Smith; a negative lookahead for a following capital eliminates a lot 125 of false positives; may be necessary to treat known names differently 126 in each language. 127 """ 128 NAME_PATTERN = self.build_book_name_regexp() 129 VERSE_MARKER = self.build_verse_marker_regexp() 130 NUMBER_LIST = self.build_number_list_regexp() 131 REGEXP = "".join( 132 [ 133 "(", 134 f"{self.END}({NAME_PATTERN})(?!{self.SPACE}[A-Z])", # John, but not John B. 135 "(", 136 "".join( 137 [ 138 f"{self.OPTIONAL_SPACE}{self.NUMBER}{self.COLON}{self.NUMBER}{self.DASH}{self.NUMBER}{self.COLON}{self.NUMBER}", # Rom 1:2-3:4 139 "|", 140 f"{self.OPTIONAL_SPACE}{self.NUMBER}{self.COLON}{NUMBER_LIST}", # Rom 3:4,6-9 141 "|", 142 f"{self.OPTIONAL_SPACE}{NUMBER_LIST}", # Phlm 3-4 (verse), Rom 3-4 (chapter) 143 ] 144 ), 145 ")?", 146 ")|(", 147 "".join( 148 [ 149 f"{self.NUMBER}{self.COLON}{self.NUMBER}{self.DASH}{self.NUMBER}{self.COLON}{self.NUMBER}", # 1:2-3:4 150 "|", 151 f"{self.NUMBER}{self.COLON}{NUMBER_LIST}", # 3:4,6-9 152 "|", 153 f"(?:{VERSE_MARKER}){NUMBER_LIST}", # vv.3-4 154 ] 155 ), 156 ")", 157 ] 158 ) 159 return REGEXP 160 161 def expand_book_aliases( 162 self, aliases: dict[str, tuple[int, int]] 163 ) -> dict[str, tuple[int, int]]: 164 """ 165 SBL style requires e.g. "1 Corinthians" be recognized as "First 166 Corinthians" at the start of a sentence. Add these as keys to 167 the aliases. 168 169 Note: 170 Number prefixes are set in the Language class. 171 """ 172 out = {} 173 for alias, library_book in aliases.items(): 174 out[alias] = library_book 175 for number, prefixes in self.language.number_prefixes.items(): 176 if alias.startswith(number + " "): 177 for prefix in prefixes: 178 out[prefix + alias[1:]] = library_book 179 return out 180 181 def build_book_name_regexp(self): 182 """ 183 Sort bookname DESC to match the longest first. 184 Replace spaces with multi-space matchers in book names. 185 Group book names by prefixes. 186 Match substitute prefixes for each prefix number: 187 188 Example: 189 ``` 190 ( (1|First|etc)\\s*(Corinthians|etc) 191 | (2|Second|etc)\\s*(Corinthians|etc) 192 | ... 193 | (Galatians|Romans|etc) 194 ) 195 ``` 196 """ 197 regexp_parts = [] 198 for number in self.numerical_book_prefixes(reverse=True): 199 aliases = [ 200 alias[len(number + " ") :] 201 for alias in self.book_alias_keys 202 if alias.startswith(number + " ") 203 ] 204 regexp_parts.append( 205 r"(?:" 206 + "|".join( 207 [number] 208 + [ 209 escape_book_name(_) 210 for _ in self.language.number_prefixes[number] 211 ] 212 ) 213 + r")" 214 + self.OPTIONAL_SPACE 215 + "(?:" 216 + "|".join([escape_book_name(_) for _ in long_names_first(aliases)]) 217 + ")" 218 # + r"(?![^A-Za-z])" # <-- Need a non-alpha lookahead? 219 + r"\b" 220 + r"\.?" 221 ) 222 leading_digits = re.compile(r"^\d+\s") 223 aliases = [ 224 alias for alias in self.book_alias_keys if not leading_digits.match(alias) 225 ] 226 regexp_parts.append( 227 r"(?:" 228 + "|".join([escape_book_name(_) for _ in long_names_first(aliases)]) 229 + ")" 230 # + r"(?![^A-Za-z])" # <-- Need a non-alpha lookahead? 231 + r"\b" 232 + r"\.?" 233 ) 234 return r"|".join(regexp_parts) 235 236 def build_verse_marker_regexp(self): 237 return "|".join( 238 [ 239 re.escape(key) + self.OPTIONAL_SPACE 240 for key in self.language.verse_markers 241 ] 242 ) 243 244 def build_number_list_regexp(self): 245 """ 246 Match a comma-(and-space?)-separated list of numbers and ranges. 247 248 A VERSE NUMBER is a number that can't be the start of a book name or chapter reference. 249 250 We use negative lookaheads to exclude book names (1 Cor) or chapter refs (6:5): 251 252 For en_US, this will look like: 253 254 ``` 255 ( 256 1(?![:.]|\\s*(?:Corinthians|Cor|Thess|etc)) 257 | 2(?![:.]|\\s*(?:Corinthians|Cor|Thess|etc)) 258 | 3(?![:.]|\\s*(?:Jn)) 259 | [4-999](?![:.]) 260 ) 261 ``` 262 """ 263 regexp_parts = [] 264 # numbers which can be book numbers, when not followed by ':' or book names 265 for number in self.numerical_book_prefixes(): 266 aliases = [ 267 alias[len(number + " ") :] 268 for alias in self.book_aliases.keys() 269 if alias.startswith(number + " ") 270 ] 271 regexp_parts.append( 272 number 273 + r"(?!" # <-- negative look-ahead 274 + self.COLON 275 + r"|" 276 + r"\s*" 277 + "(?:" # <-- non-capturing group 278 + "|".join([re.escape(key) for key in sorted(set(aliases))]) 279 + "))" 280 ) 281 282 # TODO: REMOVE (after merge) 283 284 # numbers which cannot be book numbers, when not followed by ':' 285 # if it's 4..999, then in regexp that's 4-9 or a 2 or 3 digit number 286 regexp_parts.append( 287 f"(?:[{len(regexp_parts) + 1}-9]|" + r"\d{2,3}" + f")(?!{self.COLON})" 288 ) 289 VERSE_NUMBER = r"|".join(regexp_parts) 290 REGEXP = f"(?:{self.RANGE}|{self.NUMBER})(?:{self.COMMA}(?:{self.RANGE}|(?:{VERSE_NUMBER}){self.END}))*" 291 return REGEXP 292 # regexp_parts.append(f"[{len(regexp_parts) + 1}-999]") 293 # SAFE_NUMBER = r"|".join(regexp_parts) 294 # REGEXP = f"(?:{self.RANGE}|{self.NUMBER})(?:{self.COMMA}{self.SPACE}(?:{self.RANGE}|(?:{SAFE_NUMBER}){self.END}))*" 295 296 def numerical_book_prefixes(self, reverse=True) -> list[str]: 297 prefixes = set() 298 for key in self.book_aliases.keys(): 299 part_1 = key.split(" ")[0] 300 if part_1.isdigit(): 301 prefixes.add(part_1) 302 return sorted(prefixes, reverse=reverse) 303 304 def build_brackets_regexp(self) -> str: 305 return r"[\(\)]" 306 307 def generate_references( 308 self, 309 text: str, 310 yield_books: bool = False, 311 yield_nones: bool = False, 312 use_context: bool = True, 313 ) -> Generator[tuple[str, Reference | None], None, None]: 314 """ 315 Match references and parentheses separately, then take the next lowest 316 item (by starting match position) from the regexp match generators, and 317 process. Yield references for book names and chapter/verse numbers. 318 319 This is the base function used by `__.find_references()` and 320 `__.first_reference()`. It keeps all the logic in one place. 321 322 Args: 323 text: In which to find references 324 yield_books: Whether to match book names alone 325 yield_nones: Whether to match malformed references 326 use_context: Whether to use context for number-only references. 327 """ 328 brackets_matches = self.brackets_regexp.finditer(text) 329 reference_matches = self.reference_regexp.finditer(text) 330 331 bracket_stack = [] 332 333 brackets_match = next(brackets_matches, None) 334 reference_match = next(reference_matches, None) 335 336 unnumbered_book_aliases = get_unnumbered_book_aliases(self.book_aliases) 337 338 while reference_match or brackets_match: 339 # Handle the start or end of a bracket 340 # 0 means bracket, 1 means reference 341 values = [ 342 brackets_match.start() if brackets_match else math.inf, 343 reference_match.start() if reference_match else math.inf, 344 ] 345 346 book_ref = None 347 index_of_minimum = values.index(min(values)) 348 349 if brackets_match and index_of_minimum == 0: 350 if brackets_match.group(0) == "(": 351 if len(bracket_stack) > 0: 352 bracket_stack.append(bracket_stack[-1]) 353 if brackets_match.group(0) == ")": 354 if len(bracket_stack) > 0: 355 del bracket_stack[-1] 356 brackets_match = next(brackets_matches, None) 357 358 if reference_match and index_of_minimum == 1: 359 match_str, book_name, match_with_book, match_without_book = ( 360 reference_match.groups() 361 ) 362 try: 363 if book_name: 364 respaced_book_name = add_space_after_book_number( 365 normalize_spacing(trim_trailing_period(book_name)), 366 unnumbered_book_aliases, 367 self.language.number_prefixes, 368 ) 369 370 if respaced_book_name in self.book_aliases: 371 library_id, book_id = self.book_aliases[respaced_book_name] 372 book = self.books[library_id, book_id] 373 last_range = book_reference( 374 library_id, book_id 375 ).last_range() 376 if match_with_book: 377 if matches := self.match_chapter_range(match_with_book): 378 # Rom 1:2-3:4 379 book_ref = make_chapter_range(last_range, matches) 380 if book_ref is not None or yield_nones: 381 yield (match_str, book_ref) 382 elif matches := self.match_chapter_verses( 383 match_with_book 384 ): 385 # Rom 3:4,6-9 386 book_ref = self.make_chapter_verses( 387 last_range, matches 388 ) 389 if book_ref is not None or yield_nones: 390 yield (match_str, book_ref) 391 elif matches := self.match_number_ranges( 392 match_with_book 393 ): 394 if book.chapters == 1: 395 # Phlm 3-4 (verse) 396 v = verse_range( 397 last_range.start.library, 398 last_range.start.book, 399 1, 400 1, 401 ) 402 book_ref = self.make_number_ranges(v, matches) 403 if book_ref is not None or yield_nones: 404 yield (match_str, book_ref) 405 if book.chapters > 1: 406 # Rom 3-4 (chapter) 407 book_ref = self.make_number_ranges( 408 last_range, matches, as_chapters=True 409 ) 410 if book_ref is not None or yield_nones: 411 yield (match_str, book_ref) 412 else: 413 yield (match_str, None) 414 else: # no associated reference 415 if bracket_stack: 416 bracket_stack[-1] = last_range 417 else: 418 bracket_stack.append(last_range) 419 if yield_books and ( 420 trim_trailing_period(match_str) 421 not in self.language.ambiguous_aliases 422 ): 423 yield ( 424 match_str, 425 book_reference(library_id, book_id), 426 ) 427 elif match_without_book and use_context: 428 if bracket_stack: 429 last_range = bracket_stack[-1] 430 library_id, book_id = ( 431 last_range.start.library, 432 last_range.start.book, 433 ) 434 if matches := self.match_chapter_range(match_without_book): 435 # 1:2-3:4 436 book_ref = make_chapter_range(last_range, matches) 437 if book_ref is not None or yield_nones: 438 yield (match_without_book, book_ref) 439 elif matches := self.match_chapter_verses( 440 match_without_book 441 ): 442 # 3:4,6-9 443 book_ref = self.make_chapter_verses(last_range, matches) 444 if book_ref is not None or yield_nones: 445 yield (match_without_book, book_ref) 446 elif matches := self.match_number_ranges( 447 match_without_book 448 ): 449 # v.2, vv.3-4 450 book_ref = self.make_number_ranges(last_range, matches) 451 if book_ref is not None or yield_nones: 452 yield (match_without_book, book_ref) 453 else: 454 if yield_nones: 455 yield (match_without_book, None) 456 else: 457 if yield_nones: 458 yield (match_without_book, None) 459 460 except ValueError: 461 if yield_nones: 462 yield (match_str or match_without_book, None) 463 464 if book_ref: 465 last_range = book_ref.ranges[-1] 466 if bracket_stack: 467 bracket_stack[-1] = last_range 468 else: 469 bracket_stack.append(last_range) 470 471 reference_match = next(reference_matches, None) 472 473 def match_chapter_range(self, text) -> Match | None: 474 """Match a pair of chapter-and-verse references. 475 476 Example: 477 `Rom 1:2-3:4` 478 """ 479 if match := self.CHAPTER_RANGE_CAPTURE.search(text): 480 return match 481 return None 482 483 def match_chapter_verses(self, text) -> Match | None: 484 """Match a verse list preceded by a chapter marker. 485 486 Example: 487 `Rom 3:4,6-9` 488 """ 489 if match := self.CHAPTER_VERSES_CAPTURE.search(text): 490 return match 491 return None 492 493 def match_number_ranges(self, text) -> list[str]: 494 """Match a list of numbers and number ranges. 495 496 Example: 497 `Phlm 1,3-4` (verse), `Rom 1,3-4` (chapter) 498 """ 499 return self.RANGE_OR_NUMBER_COMPILED.findall(text) 500 501 def make_number_ranges( 502 self, last: Range, matches: list[str], as_chapters: bool = False 503 ) -> Reference | None: 504 """Create a reference from a list of numbers and number ranges. 505 506 Args: 507 last: a verse to which this number list is relative. 508 matches: the result of `match_number_ranges()` 509 as_chapters: Treat these numbers as chapters rather than verses. 510 511 Example: 512 `Phlm 3-4` (verse), `Rom 3-4` (chapter) 513 514 Note: 515 If the end number is less than the start number, we check whether it 516 might be an abbreviated reference. `123-24` or even `17-8` can be 517 interpreted as `123-124` and `17-18` respectively. We test whether the 518 second number has fewer digits than the first. 519 """ 520 ranges = [] 521 for text in matches: 522 if range_matches := self.RANGE_CAPTURE.match(text): 523 start, end = range_matches.group(1, 2) 524 elif number_matches := self.NUMBER_CAPTURE.match(text): 525 start = end = number_matches.group(1) 526 else: 527 continue 528 if parse_number(end) < parse_number(start): 529 if new_end := infer_abbreviation(start, end): 530 end = new_end 531 else: 532 return None 533 if as_chapters: 534 if last.is_same_book(): 535 ranges.append( 536 range( 537 verse( 538 last.start.library, 539 last.start.book, 540 parse_number(start), 541 1, 542 ), 543 verse( 544 last.end.library, last.end.book, parse_number(end), 999 545 ), 546 ) 547 ) 548 else: 549 if last.is_same_chapter(): 550 ranges.append( 551 range( 552 verse( 553 last.start.library, 554 last.start.book, 555 last.start.chapter, 556 parse_number(start), 557 ), 558 verse( 559 last.end.library, 560 last.end.book, 561 last.end.chapter, 562 parse_number(end), 563 ), 564 ) 565 ) 566 if ranges: 567 return reference(*ranges) 568 else: 569 return None 570 571 def make_chapter_verses(self, last: Range, match: Match) -> Reference | None: 572 """Create a reference from verse list preceded by a chapter marker. 573 574 See `match_chapter_verses`. 575 576 Example: 577 `Rom 3:4,6-9` 578 """ 579 if last.is_same_book(): 580 chapter, numbers = match.groups() 581 if range_match := self.match_number_ranges(numbers): 582 last_range = range( 583 verse(last.start.library, last.start.book, int(chapter), 1), 584 verse(last.end.library, last.end.book, int(chapter), 999), 585 ) 586 if reference := self.make_number_ranges(last_range, range_match): 587 return reference 588 return None 589 590 591def make_chapter_range(last: Range, match: Match) -> Reference | None: 592 """Create pair of chapter-and-verse references from a match. 593 594 See `match_chapter_range`. 595 596 Example: 597 `Rom 1:2-3:4` 598 """ 599 if last.is_same_book(): 600 c1, v1, c2, v2 = match.groups() 601 return reference( 602 range( 603 verse( 604 last.start.library, 605 last.start.book, 606 parse_number(c1), 607 parse_number(v1), 608 ), 609 verse( 610 last.end.library, last.end.book, parse_number(c2), parse_number(v2) 611 ), 612 ) 613 ) 614 return None 615 616 617def escape_book_name(name: str) -> str: 618 """Regexp escape a name, but match multiple spaces. 619 620 This covers line wrapping, indentation, and so on, in the middle of a book 621 name. 622 """ 623 return r"\s+".join([re.escape(_) for _ in name.split(" ")]) 624 625 626def long_names_first(names: list[str]) -> list[str]: 627 """Remove duplicates and sort by length descending.""" 628 return sorted(set(names), key=len)[::-1] 629 630 631def infer_abbreviation(start: str, end: str) -> str | None: 632 """If a number range ends with a smaller number, try to infer an abbreviation. 633 634 If the end number has fewer digits the the start number then we return a 635 copy of the start number with its final digits overprinted by the end 636 number. 637 638 * In `123-24`, `24` clearly means `124`. 639 * In `12-4`, `4` should probably be interpreted as `14`. 640 * In `9-4`, `4` is simply a wrong number. We could guess `14`, but it's too 641 uncertain. 642 643 Returns: 644 None: if no inference is made. 645 """ 646 if int(end) < int(start): 647 if len(end) < len(start): 648 return start[: 0 - len(end)] + end 649 else: 650 return None 651 return end
31class Matcher: 32 """ 33 Match: 34 - Opening and closing brackets 35 - So we don't carry book context past the end of parentheses 36 - [PROPOSED] Ends of sentence and end of paragraph. 37 - So we don't carry book context for an undue distance within the text. 38 - References 39 - Book names (incl. substitutions like 'First' for '1': 40 - with no reference: sets context; only returned as a reference if 41 requested with yield_books=True 42 - when book.chapter > 1: 43 - 1:2-3:4 44 - 1:1,2-3 etc 45 - when book.chapter == 1: 46 - 1,2-4 (can be verses or chapters, based on book; must not end with 47 a number that starts a book name) 48 - Stand-alone number references: always have colons or verse markers. 49 They rely on context being provided by a previous reference or book 50 name. 51 - v.1, vv.1-2 52 - 1:2-3:4 53 - 1:1,2-3 etc (must not end with a number that starts a book name) 54 Yield: 55 - a (match_str, reference) tuple for each match. 56 """ 57 58 def __init__( 59 self, 60 books: dict[tuple[Number, Number], Book], 61 book_aliases: dict[str, tuple[Number, Number]], 62 language: Language, 63 syntax: Syntax | None = None, 64 ): 65 self.books = books 66 self.language = language 67 self.syntax = syntax or language.syntax 68 self.book_alias_keys = book_aliases.keys() 69 self.book_aliases = self.expand_book_aliases(book_aliases) 70 71 # Regexes for matching are generated on intiialisation, 72 # using the language object for punctuation. 73 74 self.SPACE = r"\s+" 75 self.OPTIONAL_SPACE = r"\s*" 76 self.END = r"\b" 77 78 self.COMMA = ( 79 "[" + re.escape(self.syntax.match_commas) + "]" + self.OPTIONAL_SPACE 80 ) 81 self.COLON = ( 82 "[" + re.escape(self.syntax.match_colons) + "]" + self.OPTIONAL_SPACE 83 ) 84 self.DASH = "[" + re.escape(self.syntax.match_dashes) + "]" 85 self.NUMBER = r"\d+[a-d]?" 86 87 self.RANGE = f"{self.NUMBER}{self.DASH}{self.NUMBER}" 88 self.LIST = f"(?:{self.RANGE}|{self.NUMBER})(?:{self.COMMA}(?:{self.RANGE}|{self.NUMBER}))*" 89 90 self.RANGE_OR_NUMBER_COMPILED = re.compile(f"({self.RANGE}|{self.NUMBER})") 91 92 self.NUMBER_CAPTURE = re.compile(f"({self.NUMBER})") 93 self.RANGE_CAPTURE = re.compile(f"({self.NUMBER}){self.DASH}({self.NUMBER})") 94 self.CHAPTER_RANGE_CAPTURE = re.compile( 95 f"{self.END}({self.NUMBER}){self.COLON}({self.NUMBER}){self.DASH}" 96 + f"({self.NUMBER}){self.COLON}({self.NUMBER}){self.END}" 97 ) 98 self.CHAPTER_VERSES_CAPTURE = re.compile( 99 f"{self.END}({self.NUMBER}){self.COLON}({self.LIST}){self.END}" 100 ) 101 102 self.brackets_regexp = re.compile(self.build_brackets_regexp()) 103 self.reference_regexp = re.compile(self.build_reference_regexp()) 104 105 def build_reference_regexp(self) -> str: 106 """ 107 Reference matches are quadruples. For the string ... 108 109 "Big Book 1:2-5 (cf. Small Book 34) is more interesting than vv.2:3-6." 110 111 ... We want to know which references are attached to books and which are just numeric: 112 113 ``` 114 Matching string 115 : Book Name 116 : : Book Reference 117 : : : Numeric Reference 118 : : : : 119 ('Big Book 1:2-5', 'Big Book', '1:2-5', None) 120 ('Small Book 34', 'Small Book', '34', None) 121 ( None, None, None, '2:3-6') 122 ``` 123 124 Additionally, we want to minimize bad matches like John Smith, or John 125 B. Smith; a negative lookahead for a following capital eliminates a lot 126 of false positives; may be necessary to treat known names differently 127 in each language. 128 """ 129 NAME_PATTERN = self.build_book_name_regexp() 130 VERSE_MARKER = self.build_verse_marker_regexp() 131 NUMBER_LIST = self.build_number_list_regexp() 132 REGEXP = "".join( 133 [ 134 "(", 135 f"{self.END}({NAME_PATTERN})(?!{self.SPACE}[A-Z])", # John, but not John B. 136 "(", 137 "".join( 138 [ 139 f"{self.OPTIONAL_SPACE}{self.NUMBER}{self.COLON}{self.NUMBER}{self.DASH}{self.NUMBER}{self.COLON}{self.NUMBER}", # Rom 1:2-3:4 140 "|", 141 f"{self.OPTIONAL_SPACE}{self.NUMBER}{self.COLON}{NUMBER_LIST}", # Rom 3:4,6-9 142 "|", 143 f"{self.OPTIONAL_SPACE}{NUMBER_LIST}", # Phlm 3-4 (verse), Rom 3-4 (chapter) 144 ] 145 ), 146 ")?", 147 ")|(", 148 "".join( 149 [ 150 f"{self.NUMBER}{self.COLON}{self.NUMBER}{self.DASH}{self.NUMBER}{self.COLON}{self.NUMBER}", # 1:2-3:4 151 "|", 152 f"{self.NUMBER}{self.COLON}{NUMBER_LIST}", # 3:4,6-9 153 "|", 154 f"(?:{VERSE_MARKER}){NUMBER_LIST}", # vv.3-4 155 ] 156 ), 157 ")", 158 ] 159 ) 160 return REGEXP 161 162 def expand_book_aliases( 163 self, aliases: dict[str, tuple[int, int]] 164 ) -> dict[str, tuple[int, int]]: 165 """ 166 SBL style requires e.g. "1 Corinthians" be recognized as "First 167 Corinthians" at the start of a sentence. Add these as keys to 168 the aliases. 169 170 Note: 171 Number prefixes are set in the Language class. 172 """ 173 out = {} 174 for alias, library_book in aliases.items(): 175 out[alias] = library_book 176 for number, prefixes in self.language.number_prefixes.items(): 177 if alias.startswith(number + " "): 178 for prefix in prefixes: 179 out[prefix + alias[1:]] = library_book 180 return out 181 182 def build_book_name_regexp(self): 183 """ 184 Sort bookname DESC to match the longest first. 185 Replace spaces with multi-space matchers in book names. 186 Group book names by prefixes. 187 Match substitute prefixes for each prefix number: 188 189 Example: 190 ``` 191 ( (1|First|etc)\\s*(Corinthians|etc) 192 | (2|Second|etc)\\s*(Corinthians|etc) 193 | ... 194 | (Galatians|Romans|etc) 195 ) 196 ``` 197 """ 198 regexp_parts = [] 199 for number in self.numerical_book_prefixes(reverse=True): 200 aliases = [ 201 alias[len(number + " ") :] 202 for alias in self.book_alias_keys 203 if alias.startswith(number + " ") 204 ] 205 regexp_parts.append( 206 r"(?:" 207 + "|".join( 208 [number] 209 + [ 210 escape_book_name(_) 211 for _ in self.language.number_prefixes[number] 212 ] 213 ) 214 + r")" 215 + self.OPTIONAL_SPACE 216 + "(?:" 217 + "|".join([escape_book_name(_) for _ in long_names_first(aliases)]) 218 + ")" 219 # + r"(?![^A-Za-z])" # <-- Need a non-alpha lookahead? 220 + r"\b" 221 + r"\.?" 222 ) 223 leading_digits = re.compile(r"^\d+\s") 224 aliases = [ 225 alias for alias in self.book_alias_keys if not leading_digits.match(alias) 226 ] 227 regexp_parts.append( 228 r"(?:" 229 + "|".join([escape_book_name(_) for _ in long_names_first(aliases)]) 230 + ")" 231 # + r"(?![^A-Za-z])" # <-- Need a non-alpha lookahead? 232 + r"\b" 233 + r"\.?" 234 ) 235 return r"|".join(regexp_parts) 236 237 def build_verse_marker_regexp(self): 238 return "|".join( 239 [ 240 re.escape(key) + self.OPTIONAL_SPACE 241 for key in self.language.verse_markers 242 ] 243 ) 244 245 def build_number_list_regexp(self): 246 """ 247 Match a comma-(and-space?)-separated list of numbers and ranges. 248 249 A VERSE NUMBER is a number that can't be the start of a book name or chapter reference. 250 251 We use negative lookaheads to exclude book names (1 Cor) or chapter refs (6:5): 252 253 For en_US, this will look like: 254 255 ``` 256 ( 257 1(?![:.]|\\s*(?:Corinthians|Cor|Thess|etc)) 258 | 2(?![:.]|\\s*(?:Corinthians|Cor|Thess|etc)) 259 | 3(?![:.]|\\s*(?:Jn)) 260 | [4-999](?![:.]) 261 ) 262 ``` 263 """ 264 regexp_parts = [] 265 # numbers which can be book numbers, when not followed by ':' or book names 266 for number in self.numerical_book_prefixes(): 267 aliases = [ 268 alias[len(number + " ") :] 269 for alias in self.book_aliases.keys() 270 if alias.startswith(number + " ") 271 ] 272 regexp_parts.append( 273 number 274 + r"(?!" # <-- negative look-ahead 275 + self.COLON 276 + r"|" 277 + r"\s*" 278 + "(?:" # <-- non-capturing group 279 + "|".join([re.escape(key) for key in sorted(set(aliases))]) 280 + "))" 281 ) 282 283 # TODO: REMOVE (after merge) 284 285 # numbers which cannot be book numbers, when not followed by ':' 286 # if it's 4..999, then in regexp that's 4-9 or a 2 or 3 digit number 287 regexp_parts.append( 288 f"(?:[{len(regexp_parts) + 1}-9]|" + r"\d{2,3}" + f")(?!{self.COLON})" 289 ) 290 VERSE_NUMBER = r"|".join(regexp_parts) 291 REGEXP = f"(?:{self.RANGE}|{self.NUMBER})(?:{self.COMMA}(?:{self.RANGE}|(?:{VERSE_NUMBER}){self.END}))*" 292 return REGEXP 293 # regexp_parts.append(f"[{len(regexp_parts) + 1}-999]") 294 # SAFE_NUMBER = r"|".join(regexp_parts) 295 # REGEXP = f"(?:{self.RANGE}|{self.NUMBER})(?:{self.COMMA}{self.SPACE}(?:{self.RANGE}|(?:{SAFE_NUMBER}){self.END}))*" 296 297 def numerical_book_prefixes(self, reverse=True) -> list[str]: 298 prefixes = set() 299 for key in self.book_aliases.keys(): 300 part_1 = key.split(" ")[0] 301 if part_1.isdigit(): 302 prefixes.add(part_1) 303 return sorted(prefixes, reverse=reverse) 304 305 def build_brackets_regexp(self) -> str: 306 return r"[\(\)]" 307 308 def generate_references( 309 self, 310 text: str, 311 yield_books: bool = False, 312 yield_nones: bool = False, 313 use_context: bool = True, 314 ) -> Generator[tuple[str, Reference | None], None, None]: 315 """ 316 Match references and parentheses separately, then take the next lowest 317 item (by starting match position) from the regexp match generators, and 318 process. Yield references for book names and chapter/verse numbers. 319 320 This is the base function used by `__.find_references()` and 321 `__.first_reference()`. It keeps all the logic in one place. 322 323 Args: 324 text: In which to find references 325 yield_books: Whether to match book names alone 326 yield_nones: Whether to match malformed references 327 use_context: Whether to use context for number-only references. 328 """ 329 brackets_matches = self.brackets_regexp.finditer(text) 330 reference_matches = self.reference_regexp.finditer(text) 331 332 bracket_stack = [] 333 334 brackets_match = next(brackets_matches, None) 335 reference_match = next(reference_matches, None) 336 337 unnumbered_book_aliases = get_unnumbered_book_aliases(self.book_aliases) 338 339 while reference_match or brackets_match: 340 # Handle the start or end of a bracket 341 # 0 means bracket, 1 means reference 342 values = [ 343 brackets_match.start() if brackets_match else math.inf, 344 reference_match.start() if reference_match else math.inf, 345 ] 346 347 book_ref = None 348 index_of_minimum = values.index(min(values)) 349 350 if brackets_match and index_of_minimum == 0: 351 if brackets_match.group(0) == "(": 352 if len(bracket_stack) > 0: 353 bracket_stack.append(bracket_stack[-1]) 354 if brackets_match.group(0) == ")": 355 if len(bracket_stack) > 0: 356 del bracket_stack[-1] 357 brackets_match = next(brackets_matches, None) 358 359 if reference_match and index_of_minimum == 1: 360 match_str, book_name, match_with_book, match_without_book = ( 361 reference_match.groups() 362 ) 363 try: 364 if book_name: 365 respaced_book_name = add_space_after_book_number( 366 normalize_spacing(trim_trailing_period(book_name)), 367 unnumbered_book_aliases, 368 self.language.number_prefixes, 369 ) 370 371 if respaced_book_name in self.book_aliases: 372 library_id, book_id = self.book_aliases[respaced_book_name] 373 book = self.books[library_id, book_id] 374 last_range = book_reference( 375 library_id, book_id 376 ).last_range() 377 if match_with_book: 378 if matches := self.match_chapter_range(match_with_book): 379 # Rom 1:2-3:4 380 book_ref = make_chapter_range(last_range, matches) 381 if book_ref is not None or yield_nones: 382 yield (match_str, book_ref) 383 elif matches := self.match_chapter_verses( 384 match_with_book 385 ): 386 # Rom 3:4,6-9 387 book_ref = self.make_chapter_verses( 388 last_range, matches 389 ) 390 if book_ref is not None or yield_nones: 391 yield (match_str, book_ref) 392 elif matches := self.match_number_ranges( 393 match_with_book 394 ): 395 if book.chapters == 1: 396 # Phlm 3-4 (verse) 397 v = verse_range( 398 last_range.start.library, 399 last_range.start.book, 400 1, 401 1, 402 ) 403 book_ref = self.make_number_ranges(v, matches) 404 if book_ref is not None or yield_nones: 405 yield (match_str, book_ref) 406 if book.chapters > 1: 407 # Rom 3-4 (chapter) 408 book_ref = self.make_number_ranges( 409 last_range, matches, as_chapters=True 410 ) 411 if book_ref is not None or yield_nones: 412 yield (match_str, book_ref) 413 else: 414 yield (match_str, None) 415 else: # no associated reference 416 if bracket_stack: 417 bracket_stack[-1] = last_range 418 else: 419 bracket_stack.append(last_range) 420 if yield_books and ( 421 trim_trailing_period(match_str) 422 not in self.language.ambiguous_aliases 423 ): 424 yield ( 425 match_str, 426 book_reference(library_id, book_id), 427 ) 428 elif match_without_book and use_context: 429 if bracket_stack: 430 last_range = bracket_stack[-1] 431 library_id, book_id = ( 432 last_range.start.library, 433 last_range.start.book, 434 ) 435 if matches := self.match_chapter_range(match_without_book): 436 # 1:2-3:4 437 book_ref = make_chapter_range(last_range, matches) 438 if book_ref is not None or yield_nones: 439 yield (match_without_book, book_ref) 440 elif matches := self.match_chapter_verses( 441 match_without_book 442 ): 443 # 3:4,6-9 444 book_ref = self.make_chapter_verses(last_range, matches) 445 if book_ref is not None or yield_nones: 446 yield (match_without_book, book_ref) 447 elif matches := self.match_number_ranges( 448 match_without_book 449 ): 450 # v.2, vv.3-4 451 book_ref = self.make_number_ranges(last_range, matches) 452 if book_ref is not None or yield_nones: 453 yield (match_without_book, book_ref) 454 else: 455 if yield_nones: 456 yield (match_without_book, None) 457 else: 458 if yield_nones: 459 yield (match_without_book, None) 460 461 except ValueError: 462 if yield_nones: 463 yield (match_str or match_without_book, None) 464 465 if book_ref: 466 last_range = book_ref.ranges[-1] 467 if bracket_stack: 468 bracket_stack[-1] = last_range 469 else: 470 bracket_stack.append(last_range) 471 472 reference_match = next(reference_matches, None) 473 474 def match_chapter_range(self, text) -> Match | None: 475 """Match a pair of chapter-and-verse references. 476 477 Example: 478 `Rom 1:2-3:4` 479 """ 480 if match := self.CHAPTER_RANGE_CAPTURE.search(text): 481 return match 482 return None 483 484 def match_chapter_verses(self, text) -> Match | None: 485 """Match a verse list preceded by a chapter marker. 486 487 Example: 488 `Rom 3:4,6-9` 489 """ 490 if match := self.CHAPTER_VERSES_CAPTURE.search(text): 491 return match 492 return None 493 494 def match_number_ranges(self, text) -> list[str]: 495 """Match a list of numbers and number ranges. 496 497 Example: 498 `Phlm 1,3-4` (verse), `Rom 1,3-4` (chapter) 499 """ 500 return self.RANGE_OR_NUMBER_COMPILED.findall(text) 501 502 def make_number_ranges( 503 self, last: Range, matches: list[str], as_chapters: bool = False 504 ) -> Reference | None: 505 """Create a reference from a list of numbers and number ranges. 506 507 Args: 508 last: a verse to which this number list is relative. 509 matches: the result of `match_number_ranges()` 510 as_chapters: Treat these numbers as chapters rather than verses. 511 512 Example: 513 `Phlm 3-4` (verse), `Rom 3-4` (chapter) 514 515 Note: 516 If the end number is less than the start number, we check whether it 517 might be an abbreviated reference. `123-24` or even `17-8` can be 518 interpreted as `123-124` and `17-18` respectively. We test whether the 519 second number has fewer digits than the first. 520 """ 521 ranges = [] 522 for text in matches: 523 if range_matches := self.RANGE_CAPTURE.match(text): 524 start, end = range_matches.group(1, 2) 525 elif number_matches := self.NUMBER_CAPTURE.match(text): 526 start = end = number_matches.group(1) 527 else: 528 continue 529 if parse_number(end) < parse_number(start): 530 if new_end := infer_abbreviation(start, end): 531 end = new_end 532 else: 533 return None 534 if as_chapters: 535 if last.is_same_book(): 536 ranges.append( 537 range( 538 verse( 539 last.start.library, 540 last.start.book, 541 parse_number(start), 542 1, 543 ), 544 verse( 545 last.end.library, last.end.book, parse_number(end), 999 546 ), 547 ) 548 ) 549 else: 550 if last.is_same_chapter(): 551 ranges.append( 552 range( 553 verse( 554 last.start.library, 555 last.start.book, 556 last.start.chapter, 557 parse_number(start), 558 ), 559 verse( 560 last.end.library, 561 last.end.book, 562 last.end.chapter, 563 parse_number(end), 564 ), 565 ) 566 ) 567 if ranges: 568 return reference(*ranges) 569 else: 570 return None 571 572 def make_chapter_verses(self, last: Range, match: Match) -> Reference | None: 573 """Create a reference from verse list preceded by a chapter marker. 574 575 See `match_chapter_verses`. 576 577 Example: 578 `Rom 3:4,6-9` 579 """ 580 if last.is_same_book(): 581 chapter, numbers = match.groups() 582 if range_match := self.match_number_ranges(numbers): 583 last_range = range( 584 verse(last.start.library, last.start.book, int(chapter), 1), 585 verse(last.end.library, last.end.book, int(chapter), 999), 586 ) 587 if reference := self.make_number_ranges(last_range, range_match): 588 return reference 589 return None
Match:
- Opening and closing brackets
- So we don't carry book context past the end of parentheses
- [PROPOSED] Ends of sentence and end of paragraph.
- So we don't carry book context for an undue distance within the text.
- References
- Book names (incl. substitutions like 'First' for '1':
- with no reference: sets context; only returned as a reference if requested with yield_books=True
- when book.chapter > 1:
- 1:2-3:4
- 1:1,2-3 etc
- when book.chapter == 1:
- 1,2-4 (can be verses or chapters, based on book; must not end with a number that starts a book name)
- Stand-alone number references: always have colons or verse markers.
They rely on context being provided by a previous reference or book
name.
- v.1, vv.1-2
- 1:2-3:4
- 1:1,2-3 etc (must not end with a number that starts a book name)
- Book names (incl. substitutions like 'First' for '1':
Yield:
- a (match_str, reference) tuple for each match.
58 def __init__( 59 self, 60 books: dict[tuple[Number, Number], Book], 61 book_aliases: dict[str, tuple[Number, Number]], 62 language: Language, 63 syntax: Syntax | None = None, 64 ): 65 self.books = books 66 self.language = language 67 self.syntax = syntax or language.syntax 68 self.book_alias_keys = book_aliases.keys() 69 self.book_aliases = self.expand_book_aliases(book_aliases) 70 71 # Regexes for matching are generated on intiialisation, 72 # using the language object for punctuation. 73 74 self.SPACE = r"\s+" 75 self.OPTIONAL_SPACE = r"\s*" 76 self.END = r"\b" 77 78 self.COMMA = ( 79 "[" + re.escape(self.syntax.match_commas) + "]" + self.OPTIONAL_SPACE 80 ) 81 self.COLON = ( 82 "[" + re.escape(self.syntax.match_colons) + "]" + self.OPTIONAL_SPACE 83 ) 84 self.DASH = "[" + re.escape(self.syntax.match_dashes) + "]" 85 self.NUMBER = r"\d+[a-d]?" 86 87 self.RANGE = f"{self.NUMBER}{self.DASH}{self.NUMBER}" 88 self.LIST = f"(?:{self.RANGE}|{self.NUMBER})(?:{self.COMMA}(?:{self.RANGE}|{self.NUMBER}))*" 89 90 self.RANGE_OR_NUMBER_COMPILED = re.compile(f"({self.RANGE}|{self.NUMBER})") 91 92 self.NUMBER_CAPTURE = re.compile(f"({self.NUMBER})") 93 self.RANGE_CAPTURE = re.compile(f"({self.NUMBER}){self.DASH}({self.NUMBER})") 94 self.CHAPTER_RANGE_CAPTURE = re.compile( 95 f"{self.END}({self.NUMBER}){self.COLON}({self.NUMBER}){self.DASH}" 96 + f"({self.NUMBER}){self.COLON}({self.NUMBER}){self.END}" 97 ) 98 self.CHAPTER_VERSES_CAPTURE = re.compile( 99 f"{self.END}({self.NUMBER}){self.COLON}({self.LIST}){self.END}" 100 ) 101 102 self.brackets_regexp = re.compile(self.build_brackets_regexp()) 103 self.reference_regexp = re.compile(self.build_reference_regexp())
105 def build_reference_regexp(self) -> str: 106 """ 107 Reference matches are quadruples. For the string ... 108 109 "Big Book 1:2-5 (cf. Small Book 34) is more interesting than vv.2:3-6." 110 111 ... We want to know which references are attached to books and which are just numeric: 112 113 ``` 114 Matching string 115 : Book Name 116 : : Book Reference 117 : : : Numeric Reference 118 : : : : 119 ('Big Book 1:2-5', 'Big Book', '1:2-5', None) 120 ('Small Book 34', 'Small Book', '34', None) 121 ( None, None, None, '2:3-6') 122 ``` 123 124 Additionally, we want to minimize bad matches like John Smith, or John 125 B. Smith; a negative lookahead for a following capital eliminates a lot 126 of false positives; may be necessary to treat known names differently 127 in each language. 128 """ 129 NAME_PATTERN = self.build_book_name_regexp() 130 VERSE_MARKER = self.build_verse_marker_regexp() 131 NUMBER_LIST = self.build_number_list_regexp() 132 REGEXP = "".join( 133 [ 134 "(", 135 f"{self.END}({NAME_PATTERN})(?!{self.SPACE}[A-Z])", # John, but not John B. 136 "(", 137 "".join( 138 [ 139 f"{self.OPTIONAL_SPACE}{self.NUMBER}{self.COLON}{self.NUMBER}{self.DASH}{self.NUMBER}{self.COLON}{self.NUMBER}", # Rom 1:2-3:4 140 "|", 141 f"{self.OPTIONAL_SPACE}{self.NUMBER}{self.COLON}{NUMBER_LIST}", # Rom 3:4,6-9 142 "|", 143 f"{self.OPTIONAL_SPACE}{NUMBER_LIST}", # Phlm 3-4 (verse), Rom 3-4 (chapter) 144 ] 145 ), 146 ")?", 147 ")|(", 148 "".join( 149 [ 150 f"{self.NUMBER}{self.COLON}{self.NUMBER}{self.DASH}{self.NUMBER}{self.COLON}{self.NUMBER}", # 1:2-3:4 151 "|", 152 f"{self.NUMBER}{self.COLON}{NUMBER_LIST}", # 3:4,6-9 153 "|", 154 f"(?:{VERSE_MARKER}){NUMBER_LIST}", # vv.3-4 155 ] 156 ), 157 ")", 158 ] 159 ) 160 return REGEXP
Reference matches are quadruples. For the string ...
"Big Book 1:2-5 (cf. Small Book 34) is more interesting than vv.2:3-6."
... We want to know which references are attached to books and which are just numeric:
Matching string
: Book Name
: : Book Reference
: : : Numeric Reference
: : : :
('Big Book 1:2-5', 'Big Book', '1:2-5', None)
('Small Book 34', 'Small Book', '34', None)
( None, None, None, '2:3-6')
Additionally, we want to minimize bad matches like John Smith, or John B. Smith; a negative lookahead for a following capital eliminates a lot of false positives; may be necessary to treat known names differently in each language.
162 def expand_book_aliases( 163 self, aliases: dict[str, tuple[int, int]] 164 ) -> dict[str, tuple[int, int]]: 165 """ 166 SBL style requires e.g. "1 Corinthians" be recognized as "First 167 Corinthians" at the start of a sentence. Add these as keys to 168 the aliases. 169 170 Note: 171 Number prefixes are set in the Language class. 172 """ 173 out = {} 174 for alias, library_book in aliases.items(): 175 out[alias] = library_book 176 for number, prefixes in self.language.number_prefixes.items(): 177 if alias.startswith(number + " "): 178 for prefix in prefixes: 179 out[prefix + alias[1:]] = library_book 180 return out
SBL style requires e.g. "1 Corinthians" be recognized as "First Corinthians" at the start of a sentence. Add these as keys to the aliases.
Note:
Number prefixes are set in the Language class.
182 def build_book_name_regexp(self): 183 """ 184 Sort bookname DESC to match the longest first. 185 Replace spaces with multi-space matchers in book names. 186 Group book names by prefixes. 187 Match substitute prefixes for each prefix number: 188 189 Example: 190 ``` 191 ( (1|First|etc)\\s*(Corinthians|etc) 192 | (2|Second|etc)\\s*(Corinthians|etc) 193 | ... 194 | (Galatians|Romans|etc) 195 ) 196 ``` 197 """ 198 regexp_parts = [] 199 for number in self.numerical_book_prefixes(reverse=True): 200 aliases = [ 201 alias[len(number + " ") :] 202 for alias in self.book_alias_keys 203 if alias.startswith(number + " ") 204 ] 205 regexp_parts.append( 206 r"(?:" 207 + "|".join( 208 [number] 209 + [ 210 escape_book_name(_) 211 for _ in self.language.number_prefixes[number] 212 ] 213 ) 214 + r")" 215 + self.OPTIONAL_SPACE 216 + "(?:" 217 + "|".join([escape_book_name(_) for _ in long_names_first(aliases)]) 218 + ")" 219 # + r"(?![^A-Za-z])" # <-- Need a non-alpha lookahead? 220 + r"\b" 221 + r"\.?" 222 ) 223 leading_digits = re.compile(r"^\d+\s") 224 aliases = [ 225 alias for alias in self.book_alias_keys if not leading_digits.match(alias) 226 ] 227 regexp_parts.append( 228 r"(?:" 229 + "|".join([escape_book_name(_) for _ in long_names_first(aliases)]) 230 + ")" 231 # + r"(?![^A-Za-z])" # <-- Need a non-alpha lookahead? 232 + r"\b" 233 + r"\.?" 234 ) 235 return r"|".join(regexp_parts)
Sort bookname DESC to match the longest first. Replace spaces with multi-space matchers in book names. Group book names by prefixes. Match substitute prefixes for each prefix number:
Example:
( (1|First|etc)\s*(Corinthians|etc) | (2|Second|etc)\s*(Corinthians|etc) | ... | (Galatians|Romans|etc) )
245 def build_number_list_regexp(self): 246 """ 247 Match a comma-(and-space?)-separated list of numbers and ranges. 248 249 A VERSE NUMBER is a number that can't be the start of a book name or chapter reference. 250 251 We use negative lookaheads to exclude book names (1 Cor) or chapter refs (6:5): 252 253 For en_US, this will look like: 254 255 ``` 256 ( 257 1(?![:.]|\\s*(?:Corinthians|Cor|Thess|etc)) 258 | 2(?![:.]|\\s*(?:Corinthians|Cor|Thess|etc)) 259 | 3(?![:.]|\\s*(?:Jn)) 260 | [4-999](?![:.]) 261 ) 262 ``` 263 """ 264 regexp_parts = [] 265 # numbers which can be book numbers, when not followed by ':' or book names 266 for number in self.numerical_book_prefixes(): 267 aliases = [ 268 alias[len(number + " ") :] 269 for alias in self.book_aliases.keys() 270 if alias.startswith(number + " ") 271 ] 272 regexp_parts.append( 273 number 274 + r"(?!" # <-- negative look-ahead 275 + self.COLON 276 + r"|" 277 + r"\s*" 278 + "(?:" # <-- non-capturing group 279 + "|".join([re.escape(key) for key in sorted(set(aliases))]) 280 + "))" 281 ) 282 283 # TODO: REMOVE (after merge) 284 285 # numbers which cannot be book numbers, when not followed by ':' 286 # if it's 4..999, then in regexp that's 4-9 or a 2 or 3 digit number 287 regexp_parts.append( 288 f"(?:[{len(regexp_parts) + 1}-9]|" + r"\d{2,3}" + f")(?!{self.COLON})" 289 ) 290 VERSE_NUMBER = r"|".join(regexp_parts) 291 REGEXP = f"(?:{self.RANGE}|{self.NUMBER})(?:{self.COMMA}(?:{self.RANGE}|(?:{VERSE_NUMBER}){self.END}))*" 292 return REGEXP 293 # regexp_parts.append(f"[{len(regexp_parts) + 1}-999]") 294 # SAFE_NUMBER = r"|".join(regexp_parts) 295 # REGEXP = f"(?:{self.RANGE}|{self.NUMBER})(?:{self.COMMA}{self.SPACE}(?:{self.RANGE}|(?:{SAFE_NUMBER}){self.END}))*"
Match a comma-(and-space?)-separated list of numbers and ranges.
A VERSE NUMBER is a number that can't be the start of a book name or chapter reference.
We use negative lookaheads to exclude book names (1 Cor) or chapter refs (6:5):
For en_US, this will look like:
(
1(?![:.]|\s*(?:Corinthians|Cor|Thess|etc))
| 2(?![:.]|\s*(?:Corinthians|Cor|Thess|etc))
| 3(?![:.]|\s*(?:Jn))
| [4-999](?![:.])
)
308 def generate_references( 309 self, 310 text: str, 311 yield_books: bool = False, 312 yield_nones: bool = False, 313 use_context: bool = True, 314 ) -> Generator[tuple[str, Reference | None], None, None]: 315 """ 316 Match references and parentheses separately, then take the next lowest 317 item (by starting match position) from the regexp match generators, and 318 process. Yield references for book names and chapter/verse numbers. 319 320 This is the base function used by `__.find_references()` and 321 `__.first_reference()`. It keeps all the logic in one place. 322 323 Args: 324 text: In which to find references 325 yield_books: Whether to match book names alone 326 yield_nones: Whether to match malformed references 327 use_context: Whether to use context for number-only references. 328 """ 329 brackets_matches = self.brackets_regexp.finditer(text) 330 reference_matches = self.reference_regexp.finditer(text) 331 332 bracket_stack = [] 333 334 brackets_match = next(brackets_matches, None) 335 reference_match = next(reference_matches, None) 336 337 unnumbered_book_aliases = get_unnumbered_book_aliases(self.book_aliases) 338 339 while reference_match or brackets_match: 340 # Handle the start or end of a bracket 341 # 0 means bracket, 1 means reference 342 values = [ 343 brackets_match.start() if brackets_match else math.inf, 344 reference_match.start() if reference_match else math.inf, 345 ] 346 347 book_ref = None 348 index_of_minimum = values.index(min(values)) 349 350 if brackets_match and index_of_minimum == 0: 351 if brackets_match.group(0) == "(": 352 if len(bracket_stack) > 0: 353 bracket_stack.append(bracket_stack[-1]) 354 if brackets_match.group(0) == ")": 355 if len(bracket_stack) > 0: 356 del bracket_stack[-1] 357 brackets_match = next(brackets_matches, None) 358 359 if reference_match and index_of_minimum == 1: 360 match_str, book_name, match_with_book, match_without_book = ( 361 reference_match.groups() 362 ) 363 try: 364 if book_name: 365 respaced_book_name = add_space_after_book_number( 366 normalize_spacing(trim_trailing_period(book_name)), 367 unnumbered_book_aliases, 368 self.language.number_prefixes, 369 ) 370 371 if respaced_book_name in self.book_aliases: 372 library_id, book_id = self.book_aliases[respaced_book_name] 373 book = self.books[library_id, book_id] 374 last_range = book_reference( 375 library_id, book_id 376 ).last_range() 377 if match_with_book: 378 if matches := self.match_chapter_range(match_with_book): 379 # Rom 1:2-3:4 380 book_ref = make_chapter_range(last_range, matches) 381 if book_ref is not None or yield_nones: 382 yield (match_str, book_ref) 383 elif matches := self.match_chapter_verses( 384 match_with_book 385 ): 386 # Rom 3:4,6-9 387 book_ref = self.make_chapter_verses( 388 last_range, matches 389 ) 390 if book_ref is not None or yield_nones: 391 yield (match_str, book_ref) 392 elif matches := self.match_number_ranges( 393 match_with_book 394 ): 395 if book.chapters == 1: 396 # Phlm 3-4 (verse) 397 v = verse_range( 398 last_range.start.library, 399 last_range.start.book, 400 1, 401 1, 402 ) 403 book_ref = self.make_number_ranges(v, matches) 404 if book_ref is not None or yield_nones: 405 yield (match_str, book_ref) 406 if book.chapters > 1: 407 # Rom 3-4 (chapter) 408 book_ref = self.make_number_ranges( 409 last_range, matches, as_chapters=True 410 ) 411 if book_ref is not None or yield_nones: 412 yield (match_str, book_ref) 413 else: 414 yield (match_str, None) 415 else: # no associated reference 416 if bracket_stack: 417 bracket_stack[-1] = last_range 418 else: 419 bracket_stack.append(last_range) 420 if yield_books and ( 421 trim_trailing_period(match_str) 422 not in self.language.ambiguous_aliases 423 ): 424 yield ( 425 match_str, 426 book_reference(library_id, book_id), 427 ) 428 elif match_without_book and use_context: 429 if bracket_stack: 430 last_range = bracket_stack[-1] 431 library_id, book_id = ( 432 last_range.start.library, 433 last_range.start.book, 434 ) 435 if matches := self.match_chapter_range(match_without_book): 436 # 1:2-3:4 437 book_ref = make_chapter_range(last_range, matches) 438 if book_ref is not None or yield_nones: 439 yield (match_without_book, book_ref) 440 elif matches := self.match_chapter_verses( 441 match_without_book 442 ): 443 # 3:4,6-9 444 book_ref = self.make_chapter_verses(last_range, matches) 445 if book_ref is not None or yield_nones: 446 yield (match_without_book, book_ref) 447 elif matches := self.match_number_ranges( 448 match_without_book 449 ): 450 # v.2, vv.3-4 451 book_ref = self.make_number_ranges(last_range, matches) 452 if book_ref is not None or yield_nones: 453 yield (match_without_book, book_ref) 454 else: 455 if yield_nones: 456 yield (match_without_book, None) 457 else: 458 if yield_nones: 459 yield (match_without_book, None) 460 461 except ValueError: 462 if yield_nones: 463 yield (match_str or match_without_book, None) 464 465 if book_ref: 466 last_range = book_ref.ranges[-1] 467 if bracket_stack: 468 bracket_stack[-1] = last_range 469 else: 470 bracket_stack.append(last_range) 471 472 reference_match = next(reference_matches, None)
Match references and parentheses separately, then take the next lowest item (by starting match position) from the regexp match generators, and process. Yield references for book names and chapter/verse numbers.
This is the base function used by __.find_references() and
__.first_reference(). It keeps all the logic in one place.
Arguments:
- text: In which to find references
- yield_books: Whether to match book names alone
- yield_nones: Whether to match malformed references
- use_context: Whether to use context for number-only references.
474 def match_chapter_range(self, text) -> Match | None: 475 """Match a pair of chapter-and-verse references. 476 477 Example: 478 `Rom 1:2-3:4` 479 """ 480 if match := self.CHAPTER_RANGE_CAPTURE.search(text): 481 return match 482 return None
Match a pair of chapter-and-verse references.
Example:
Rom 1:2-3:4
484 def match_chapter_verses(self, text) -> Match | None: 485 """Match a verse list preceded by a chapter marker. 486 487 Example: 488 `Rom 3:4,6-9` 489 """ 490 if match := self.CHAPTER_VERSES_CAPTURE.search(text): 491 return match 492 return None
Match a verse list preceded by a chapter marker.
Example:
Rom 3:4,6-9
494 def match_number_ranges(self, text) -> list[str]: 495 """Match a list of numbers and number ranges. 496 497 Example: 498 `Phlm 1,3-4` (verse), `Rom 1,3-4` (chapter) 499 """ 500 return self.RANGE_OR_NUMBER_COMPILED.findall(text)
Match a list of numbers and number ranges.
Example:
Phlm 1,3-4(verse),Rom 1,3-4(chapter)
502 def make_number_ranges( 503 self, last: Range, matches: list[str], as_chapters: bool = False 504 ) -> Reference | None: 505 """Create a reference from a list of numbers and number ranges. 506 507 Args: 508 last: a verse to which this number list is relative. 509 matches: the result of `match_number_ranges()` 510 as_chapters: Treat these numbers as chapters rather than verses. 511 512 Example: 513 `Phlm 3-4` (verse), `Rom 3-4` (chapter) 514 515 Note: 516 If the end number is less than the start number, we check whether it 517 might be an abbreviated reference. `123-24` or even `17-8` can be 518 interpreted as `123-124` and `17-18` respectively. We test whether the 519 second number has fewer digits than the first. 520 """ 521 ranges = [] 522 for text in matches: 523 if range_matches := self.RANGE_CAPTURE.match(text): 524 start, end = range_matches.group(1, 2) 525 elif number_matches := self.NUMBER_CAPTURE.match(text): 526 start = end = number_matches.group(1) 527 else: 528 continue 529 if parse_number(end) < parse_number(start): 530 if new_end := infer_abbreviation(start, end): 531 end = new_end 532 else: 533 return None 534 if as_chapters: 535 if last.is_same_book(): 536 ranges.append( 537 range( 538 verse( 539 last.start.library, 540 last.start.book, 541 parse_number(start), 542 1, 543 ), 544 verse( 545 last.end.library, last.end.book, parse_number(end), 999 546 ), 547 ) 548 ) 549 else: 550 if last.is_same_chapter(): 551 ranges.append( 552 range( 553 verse( 554 last.start.library, 555 last.start.book, 556 last.start.chapter, 557 parse_number(start), 558 ), 559 verse( 560 last.end.library, 561 last.end.book, 562 last.end.chapter, 563 parse_number(end), 564 ), 565 ) 566 ) 567 if ranges: 568 return reference(*ranges) 569 else: 570 return None
Create a reference from a list of numbers and number ranges.
Arguments:
- last: a verse to which this number list is relative.
- matches: the result of
match_number_ranges() - as_chapters: Treat these numbers as chapters rather than verses.
Example:
Phlm 3-4(verse),Rom 3-4(chapter)
Note:
If the end number is less than the start number, we check whether it might be an abbreviated reference.
123-24or even17-8can be interpreted as123-124and17-18respectively. We test whether the second number has fewer digits than the first.
572 def make_chapter_verses(self, last: Range, match: Match) -> Reference | None: 573 """Create a reference from verse list preceded by a chapter marker. 574 575 See `match_chapter_verses`. 576 577 Example: 578 `Rom 3:4,6-9` 579 """ 580 if last.is_same_book(): 581 chapter, numbers = match.groups() 582 if range_match := self.match_number_ranges(numbers): 583 last_range = range( 584 verse(last.start.library, last.start.book, int(chapter), 1), 585 verse(last.end.library, last.end.book, int(chapter), 999), 586 ) 587 if reference := self.make_number_ranges(last_range, range_match): 588 return reference 589 return None
Create a reference from verse list preceded by a chapter marker.
See match_chapter_verses.
Example:
Rom 3:4,6-9
592def make_chapter_range(last: Range, match: Match) -> Reference | None: 593 """Create pair of chapter-and-verse references from a match. 594 595 See `match_chapter_range`. 596 597 Example: 598 `Rom 1:2-3:4` 599 """ 600 if last.is_same_book(): 601 c1, v1, c2, v2 = match.groups() 602 return reference( 603 range( 604 verse( 605 last.start.library, 606 last.start.book, 607 parse_number(c1), 608 parse_number(v1), 609 ), 610 verse( 611 last.end.library, last.end.book, parse_number(c2), parse_number(v2) 612 ), 613 ) 614 ) 615 return None
Create pair of chapter-and-verse references from a match.
See match_chapter_range.
Example:
Rom 1:2-3:4
618def escape_book_name(name: str) -> str: 619 """Regexp escape a name, but match multiple spaces. 620 621 This covers line wrapping, indentation, and so on, in the middle of a book 622 name. 623 """ 624 return r"\s+".join([re.escape(_) for _ in name.split(" ")])
Regexp escape a name, but match multiple spaces.
This covers line wrapping, indentation, and so on, in the middle of a book name.
627def long_names_first(names: list[str]) -> list[str]: 628 """Remove duplicates and sort by length descending.""" 629 return sorted(set(names), key=len)[::-1]
Remove duplicates and sort by length descending.
632def infer_abbreviation(start: str, end: str) -> str | None: 633 """If a number range ends with a smaller number, try to infer an abbreviation. 634 635 If the end number has fewer digits the the start number then we return a 636 copy of the start number with its final digits overprinted by the end 637 number. 638 639 * In `123-24`, `24` clearly means `124`. 640 * In `12-4`, `4` should probably be interpreted as `14`. 641 * In `9-4`, `4` is simply a wrong number. We could guess `14`, but it's too 642 uncertain. 643 644 Returns: 645 None: if no inference is made. 646 """ 647 if int(end) < int(start): 648 if len(end) < len(start): 649 return start[: 0 - len(end)] + end 650 else: 651 return None 652 return end
If a number range ends with a smaller number, try to infer an abbreviation.
If the end number has fewer digits the the start number then we return a copy of the start number with its final digits overprinted by the end number.
- In
123-24,24clearly means124. - In
12-4,4should probably be interpreted as14. - In
9-4,4is simply a wrong number. We could guess14, but it's too uncertain.
Returns:
None: if no inference is made.