From ddb7867a8807765704146dbbebf9c96c96963e85 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sun, 9 Aug 2026 18:26:06 +0200 Subject: [PATCH] Refactor ocr.py: dedupe the coordinate/type-id regex fragments ocr.py grew through many incremental patches and had accumulated the same two shapes copy-pasted with minor variation across the file: - The full coordinate shape ('H3 5:5', letter + big-cell number + sub- grid x:y) was hand-restated as a regex literal in six places (_COORD_RE, _GRID_COORD_RE, _REQUESTED_ON_COORD_RE, the FO-report patterns, _NAMED_AT_COORD_RE, _STATION_LINE_RE), each pairing its own copy with its own try/except Coord(...) construction. Factored into one _COORD_FRAGMENT regex piece and one _coord_from_groups() helper, every call site now just embeds/calls it. - The '' type-id shape ('AmmoCache#3') was similarly restated across _NAMED_HEADER_RE, _REF_NAMED_RE, and _DESTROYED_RE. Factored into _TYPE_ID_FRAGMENT. - Dropped _SEP, an unused leftover regex fragment. No parsing behavior changed: every format ocr.py understands (standard target/RP blocks, calibration line, destroyed reports, train-arrival intel, ad-hoc Enemy installations and their destroyed reports, Listening Post/Coastal Battery, Marine Garrison fire-support requests, multi-word RP names, bare-name-header targets, bold-span coordinate squashing, FO-report triangulation, the ': ' clue grammar, 16-point compass tolerance, grid-only coords) is still covered by the full test suite, all 21 tests pass unchanged, plus a direct re-run of the exact 'Enemy#name' example that prompted this session's bug report to confirm detection by name still works. --- src/fenigma/ocr.py | 101 ++++++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 57 deletions(-) diff --git a/src/fenigma/ocr.py b/src/fenigma/ocr.py index f3456fe..d0139b2 100644 --- a/src/fenigma/ocr.py +++ b/src/fenigma/ocr.py @@ -94,10 +94,26 @@ _DIGIT_FIX = str.maketrans({ "Z": "2", "z": "2", "G": "6", "g": "6", }) -_SEP = r"\s*[-–—]\s*" -_COORD_RE = re.compile( - rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})" -) +# The full-precision coordinate shape ('H3 5:5', 'Q4 4:2'), one big-grid +# letter, one big-grid number, then the sub-grid x:y pair. Every coord +# regex in this module that captures a full coordinate (not just the +# large-cell letter+number, see _LARGE_GRID_ONLY_RE) embeds this exact +# fragment rather than restating it, so its four capture groups are +# always (letter, big_y, x, y) in that order, matching what +# _coord_from_groups() below expects. +_COORD_FRAGMENT = rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})" +_COORD_RE = re.compile(_COORD_FRAGMENT) + + +def _coord_from_groups(letter: str, y: str, x: str, yy: str) -> Coord | None: + """Build a Coord from a _COORD_FRAGMENT match's four groups, digit- + fixing each numeric one, None on an out-of-range result rather than + raising (OCR garbage can still parse as *a* number, just not a valid + grid position).""" + try: + return Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy))) + except ValueError: + return None # No literal '#' required, it's just as OCR-corruptible as anything else # (missing entirely, or misread as e.g. 'H'). We instead anchor to *where* # the fuzzy keyword match ended and take the first run of digit-shaped @@ -137,29 +153,19 @@ def _fix_id_digits(raw: str) -> str: # A target can also be spotted with an absolute grid ref directly # ("Target#10 Spotted. Grid Q3 9:0") instead of/alongside bearing/distance # clues, same coordinate shape as _COORD_RE, just anchored after "Grid". -_GRID_COORD_RE = re.compile( - rf"Grid\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})", - re.IGNORECASE, -) +_GRID_COORD_RE = re.compile(rf"Grid\s+{_COORD_FRAGMENT}", re.IGNORECASE) def _extract_grid_coord(text: str) -> Coord | None: m = _GRID_COORD_RE.search(text) if not m: return None - letter, y, x, yy = m.groups() - try: - return Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy))) - except ValueError: - return None + return _coord_from_groups(*m.groups()) # A fire-support request gives its coord differently again: " # Shells requested on ", no "Grid" keyword. Same shape otherwise. -_REQUESTED_ON_COORD_RE = re.compile( - rf"requested\s+on\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})", - re.IGNORECASE, -) +_REQUESTED_ON_COORD_RE = re.compile(rf"requested\s+on\s+{_COORD_FRAGMENT}", re.IGNORECASE) # "SMK Shells requested on ..." names the shell by its short code, one of # the Shell enum's own member names. _SHELL_REQUEST_RE = re.compile(r"([A-Za-z]+)\s+Shells?\s+requested", re.IGNORECASE) @@ -173,11 +179,7 @@ def _extract_requested_on_coord(text: str) -> Coord | None: m = _REQUESTED_ON_COORD_RE.search(text) if not m: return None - letter, y, x, yy = m.groups() - try: - return Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy))) - except ValueError: - return None + return _coord_from_groups(*m.groups()) def _extract_shell_request(text: str) -> Shell | None: @@ -219,16 +221,7 @@ def _extract_coord(line: str) -> Coord | None: m = _COORD_RE.search(line) if not m: return None - letter, y, x, yy = m.groups() - try: - return Coord( - X=letter.upper(), - Y=int(_fix_digits(y)), - x=int(_fix_digits(x)), - y=int(_fix_digits(yy)), - ) - except ValueError: - return None # out-of-range numbers -> not actually a coordinate + return _coord_from_groups(*m.groups()) def _extract_leading_id(remainder: str) -> int | None: @@ -268,7 +261,13 @@ _RP_HEADER_RE = re.compile(r"Reference\s+Point\s+([A-Za-z][\w-]*)\s*:?", re.IGNO # header. Requiring real punctuation between word and digits (true of # every observed header: '#', a misread substitute, ...) rules that out, # and also stops a bare "Word 094" clue line (space only) from matching. -_NAMED_HEADER_RE = re.compile(rf"^([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)\s*:?\s*(.*)$") +# +# This '' shape ('AmmoCache#3', 'HostileTank#3') is +# what a "Type#id" reference/header looks like anywhere in this module, +# _NAMED_HEADER_RE, _REF_NAMED_RE, and _DESTROYED_RE below all embed this +# same fragment rather than restating it. +_TYPE_ID_FRAGMENT = rf"([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)" +_NAMED_HEADER_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}\s*:?\s*(.*)$") # Last-resort header: a name with no keyword and no id at all, just # ':' after squashing ('HMS Rockingham:' -> 'HMSRockingham:'). The @@ -330,11 +329,7 @@ def squash_multiword_ids(text: str) -> str: # anywhere rather than tied to a section keyword, this shape is generic # enough (not specific to "Listening Post") to catch whatever else turns # up named this way. -_NAMED_AT_COORD_RE = re.compile( - rf"([A-Za-z]+)#({_DIGIT_CLASS}+)\s+at\s+([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})" - rf"\s*[:;.,]\s*({_DIGIT_CLASS})", - re.IGNORECASE, -) +_NAMED_AT_COORD_RE = re.compile(rf"([A-Za-z]+)#({_DIGIT_CLASS}+)\s+at\s+{_COORD_FRAGMENT}", re.IGNORECASE) def parse_named_at_coord(text: str) -> list[dict]: @@ -344,9 +339,8 @@ def parse_named_at_coord(text: str) -> list[dict]: entries = [] for m in _NAMED_AT_COORD_RE.finditer(text): word, num, letter, y, x, yy = m.groups() - try: - coord = Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy))) - except ValueError: + coord = _coord_from_groups(letter, y, x, yy) + if coord is None: continue entries.append({ "kind": "rp", "name": f"{word}#{_fix_id_digits(num)}", "type_word": None, "id": None, @@ -368,15 +362,14 @@ def parse_named_at_coord(text: str) -> list[dict]: # let the bearing pattern's optional '°' swallow part of a distance # reading if bearing ran first. _FO_TARGET_NAME = r"([A-Za-z][A-Za-z0-9'\s]*?)" -_FO_COORD = rf"([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})" _FO_DISTANCE_RE = re.compile( rf"FO(?:#({_DIGIT_CLASS}+))?\s+(?:Audio\s+report\s+on|Eyes\s+on)\s+{_FO_TARGET_NAME}\s*:\s*" - rf"([\d.]+)\s*k?m\s+From\s+{_FO_COORD}", + rf"([\d.]+)\s*k?m\s+From\s+{_COORD_FRAGMENT}", re.IGNORECASE, ) _FO_BEARING_RE = re.compile( rf"FO(?:#({_DIGIT_CLASS}+))?\s+(?:Audio\s+report\s+on|Eyes\s+on)\s+{_FO_TARGET_NAME}\s*:\s*" - rf"({_DIGIT_CLASS}{{1,3}})\s*°?\s+From\s+{_FO_COORD}", + rf"({_DIGIT_CLASS}{{1,3}})\s*°?\s+From\s+{_COORD_FRAGMENT}", re.IGNORECASE, ) @@ -407,9 +400,8 @@ def parse_fo_reports(text: str) -> dict[str, tuple[str, Coord | None]]: consumed.append(span) fo_num, target_name, value, letter, y, x, yy = m.groups() fo_name = f"FO#{_fix_id_digits(fo_num)}" if fo_num else "FO" - try: - coord = Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy))) - except ValueError: + coord = _coord_from_groups(letter, y, x, yy) + if coord is None: return target_key = "".join(re.sub(r"[^A-Za-z0-9]", "", w) for w in target_name.split()) bearing = None if is_distance else float(_fix_digits(value)) @@ -527,7 +519,7 @@ _TYPE_BY_SHORT = {t.short: t for t in TargetType} _TYPE_WORD_ALIASES = {"AmmoCache": "SupplyCache"} -_REF_NAMED_RE = re.compile(rf"^([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)") +_REF_NAMED_RE = re.compile(rf"^{_TYPE_ID_FRAGMENT}") def _clean_reference(raw: str) -> str: @@ -770,9 +762,7 @@ _ARRIVAL_STATION_KEYWORD = "ARRIVAL STATION" _TRACK_ALIGNMENT_KEYWORD = "TRACK ALIGNMENT" _FINAL_APPROACH_KEYWORD = "FINAL APPROACH" -_STATION_LINE_RE = re.compile( - rf"(\S+):\s*([A-T])\s*({_DIGIT_CLASS}{{1,2}})\s+({_DIGIT_CLASS})\s*[:;.,]\s*({_DIGIT_CLASS})" -) +_STATION_LINE_RE = re.compile(rf"(\S+):\s*{_COORD_FRAGMENT}") _WAYPOINT_RE = re.compile(r"Waypoint\s+(\S+)\s*-\s*([\d.]+)\s*k?m\s+from\s+(\S+)", re.IGNORECASE) @@ -810,9 +800,8 @@ def parse_train_intel(text: str) -> list[dict]: if station_m is None: return [] station_name, letter, y, x, yy = station_m.groups() - try: - station_coord = Coord(X=letter.upper(), Y=int(_fix_digits(y)), x=int(_fix_digits(x)), y=int(_fix_digits(yy))) - except ValueError: + station_coord = _coord_from_groups(letter, y, x, yy) + if station_coord is None: return [] entries = [{ @@ -848,9 +837,7 @@ def parse_train_intel(text: str) -> list[dict]: # Just need "#" immediately followed by "Destroyed", the # "Direct Hit!" prefix (or its absence) doesn't matter, search() finds # the name+Destroyed pair anywhere in the line either way. -_DESTROYED_RE = re.compile( - rf"([A-Za-z]+)[^A-Za-z0-9\s]{{1,2}}({_DIGIT_CLASS}+)\s*Destroyed", re.IGNORECASE -) +_DESTROYED_RE = re.compile(rf"{_TYPE_ID_FRAGMENT}\s*Destroyed", re.IGNORECASE) # Same digit-vs-letter-id split as the header regexes: an ad-hoc "Enemy # X Y" installation's id (after squash_enemy_names()) is letters, not # digits, so it needs its own pattern, _DESTROYED_RE's digit class won't