"""Lossless word-span mapping from OSHB/MorphGNT to the site's Kougo references. The OSHB map describes full verses. Explicit, source-checked word boundaries handle its partial verses. Psalm superscriptions use verse 0, never overwrite 1. """ from __future__ import annotations import json import re from collections import defaultdict from pathlib import Path from xml.etree import ElementTree DATA = Path(__file__).resolve().parents[2] / 'data' OT_IDS = 'genesis exodus leviticus numbers deuteronomy joshua judges ruth 1samuel 2samuel 1kings 2kings 1chronicles 2chronicles ezra nehemiah esther job psalms proverbs ecclesiastes song_of_solomon isaiah jeremiah lamentations ezekiel daniel hosea joel amos obadiah jonah micah nahum habakkuk zephaniah haggai zechariah malachi'.split() OSIS = 'Gen Exod Lev Num Deut Josh Judg Ruth 1Sam 2Sam 1Kgs 2Kgs 1Chr 2Chr Ezra Neh Esth Job Ps Prov Eccl Song Isa Jer Lam Ezek Dan Hos Joel Amos Obad Jonah Mic Nah Hab Zeph Hag Zech Mal'.split() OSIS_BOOK = dict(zip(OSIS, OT_IDS, strict=True)) # (source book, chapter, verse): [(target chapter, verse, start word, end word)] # Half-open, zero-based word spans; None means the end of the source verse. PARTIAL = { ('1kings', 18, 34): [(18, 33, 0, 10), (18, 34, 10, None)], ('1kings', 20, 3): [(20, 2, 0, 6), (20, 3, 6, None)], ('1kings', 22, 21): [(22, 21, 0, 8), (22, 22, 8, None)], ('1kings', 22, 44): [(22, 43, 0, None)], ('isaiah', 63, 19): [(63, 19, 0, 9), (64, 1, 9, None)], ('psalms', 13, 6): [(13, 5, 0, 6), (13, 6, 6, None)], } BOUNDARIES = { ('1kings', 18, 34): (10, 'ויאמר'), ('1kings', 20, 3): (6, 'כספך'), ('1kings', 22, 21): (8, 'ויאמר'), ('isaiah', 63, 19): (9, 'לוא'), ('psalms', 13, 6): (6, 'אשירה'), ('zechariah', 2, 15): (12, 'וידעת'), } def parse_osis(ref): book, chapter, verse = ref.split('!')[0].split('.') return OSIS_BOOK[book], int(chapter), int(verse) def load_map(path=DATA / 'versification/oshb-VerseMap.xml'): mapping = {} partial_keys = set() for item in ElementTree.parse(path).iter('{http://www.APTBibleTools.com/namespace}verse'): source, target = parse_osis(item.attrib['wlc']), parse_osis(item.attrib['kjv']) if item.attrib['type'] == 'full': if source in mapping: raise ValueError(f'Duplicate source mapping: {source}') mapping[source] = [(target[1], target[2], 0, None)] elif item.attrib['type'] == 'partial': partial_keys.add(source) else: raise ValueError(f'Unknown map type: {item.attrib}') if partial_keys != set(PARTIAL): raise ValueError('Partial verse map changed; review word boundaries') mapping.update(PARTIAL) # Hebrew headings precede the source verse mapped to English verse 1. for (book, chapter, verse), targets in list(mapping.items()): if book == 'psalms' and targets[0][:2] == (chapter, 1): for heading in range(1, verse): mapping[(book, chapter, heading)] = [(chapter, 0, 0, None)] # Greek/SBLGNT numbering differs from the main site's printed units here. mapping[('3john', 1, 15)] = [(1, 14, 0, None)] mapping[('revelation', 12, 18)] = [(12, 17, 0, None)] # Kougo puts the closing recognition formula in 2:12 (KJV has it in 2:11). mapping[('zechariah', 2, 15)] = [(2, 11, 0, 12), (2, 12, 12, None)] return mapping def spans_for(book, chapter, verse, record, mapping): words = record['words'] key = (book, chapter, verse) spans = mapping.get(key, [(chapter, verse, 0, None)]) if key in BOUNDARIES: offset, expected = BOUNDARIES[key] if offset >= len(words) or re.sub(r'[^א-ת]', '', words[offset]['word']) != expected: raise ValueError(f'Source changed at reviewed boundary: {key}') if key == ('acts', 19, 40): # Six-word closing sentence belongs to Acts 19:41 in Kougo/KJV. ending = 'καὶ ταῦτα εἰπὼν ἀπέλυσεν τὴν ἐκκλησίαν' if ' '.join(w['word'] for w in words[-6:]) != ending: raise ValueError('Acts 19:40 closing sentence changed') spans = [(19, 40, 0, len(words)-6), (19, 41, len(words)-6, None)] covered = [] result = [] for ch, v, start, end in spans: stop = len(words) if end is None else end if not 0 <= start < stop <= len(words): raise ValueError(f'Invalid word span: {key}') covered.extend(range(start, stop)) result.append(((book, ch, v), start, stop)) if covered != list(range(len(words))): raise ValueError(f'Word loss or duplication: {key}') return result STRONGS_HEAD = re.compile(r'(\d+)') def hebrew_strongs(lemma): """The head Strong's number from a morphhb lemma. morphhb lemmas are augmented: prefixes are separated with "/" and some carry a homonym letter ("1254 a"). The head word is the last numeric segment, which is what a dictionary or a concordance is keyed on. """ parts = [p for p in (lemma or '').split('/') if STRONGS_HEAD.search(p)] if not parts: return '' found = STRONGS_HEAD.search(parts[-1]) return f'H{int(found.group(1))}' if found else '' def greek_strongs(data_dir): """Strong's numbers per Greek verse, in word order. From macula-greek, never from MorphGNT: its lemma and parsing fields are CC BY-SA and this project deliberately does not distribute them. """ path = data_dir / 'sources/greek_strongs.json' if not path.exists(): return {} return json.loads(path.read_text())['verses'] def build_originals(data_dir=DATA): mapping = load_map(data_dir / 'versification/oshb-VerseMap.xml') greek_keys = greek_strongs(data_dir) targets = defaultdict(list) source_map = {} for language in ('hebrew', 'greek'): for path in sorted((data_dir / 'sources' / language).glob('*.json')): book = path.stem for ref, record in json.loads(path.read_text()).items(): ch, v = map(int, ref.split(':')) source = (book, ch, v) source_map[source] = spans_for(book, ch, v, record, mapping) for target, start, stop in source_map[source]: # Language is identified per word, including mixed verses. for offset in range(start, stop): word = record['words'][offset] if language == 'hebrew' and not word.get('morph', '').startswith(('H', 'A')): raise ValueError(f'Unknown word language: {source}:{offset}') lang = 'grc' if language == 'greek' else ('arc' if word['morph'].startswith('A') else 'he') text = word['word'].replace('/', '') if language == 'greek': keys = greek_keys.get(f'{book}:{ch}:{v}', []) strong = keys[offset] if offset < len(keys) else '' else: strong = hebrew_strongs(word.get('lemma')) targets[target].append({'language': lang, 'text': text, 'strong': strong, 'source': f'{book}:{ch}:{v}', 'word': offset}) # Sort by source reference and word position, including merged verses. for words in targets.values(): words.sort(key=lambda w: (*map(int, w['source'].split(':')[1:]), w['word'])) return dict(targets), source_map