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 '<word><junk><digits>' 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 '<ref>: <value>' 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.
This commit is contained in:
parent
4b427e5b0d
commit
ddb7867a88
@ -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: "<Shell>
|
||||
# Shells requested on <coord>", 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 '<word><junk><digits>' 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
|
||||
# '<Name>:' 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 "<Type>#<id>" 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user