diff --git a/src/fenigma/models.py b/src/fenigma/models.py index 97ea9ff..afe2271 100644 --- a/src/fenigma/models.py +++ b/src/fenigma/models.py @@ -44,6 +44,7 @@ class TargetType(Enum): HOSTILE_ARTILLERY = "Hostile Artillery" HOSTILE_TANK = "Hostile Tank" PILLBOX = "Pillbox" # armoured emplacement, fixed position + COASTAL_BATTERY = "Coastal Battery" # heavy fixed emplacement, reported by listening posts ENEMY = "Enemy" # ad-hoc installation named directly in the intel text # ("Enemy Signal Station", "Enemy Field Command"), not one of the # game's fixed unit types, its id is the rest of that name with diff --git a/src/fenigma/ocr.py b/src/fenigma/ocr.py index 02b3961..5c98e2e 100644 --- a/src/fenigma/ocr.py +++ b/src/fenigma/ocr.py @@ -231,6 +231,60 @@ def squash_enemy_names(text: str) -> str: return _ENEMY_NAME_RE.sub(lambda m: "Enemy#" + "".join(m.group(0).split()[1:]), text) +# Some named things have a multi-word type ("Coastal Battery#2", +# "Listening Post#1") but, unlike the Enemy case above, already come with +# a real digit id attached, nothing to invent. Only the embedded space +# needs collapsing so _NAMED_HEADER_RE and every from- clue pattern +# see the single token they expect ('CoastalBattery#2'). Requires 2+ +# words specifically, a single-word id like 'AmmoCache#3' already works +# and shouldn't be touched here. Each word must be Title Case, same +# reasoning as _ENEMY_NAME_RE: without it, ordinary lowercase prose right +# before some unrelated '#N' ("...South-East from Listening Post#1" -> +# matching backward from "Post#1" through "from") gets swallowed into the +# 'name' too, an all-lowercase connector word is never actually part of +# one of these names. +_MULTIWORD_ID_RE = re.compile(rf"\b([A-Z][a-zA-Z]*(?:[ \t]+[A-Z][a-zA-Z]*){{1,3}})\s*#\s*({_DIGIT_CLASS}+)") + + +def squash_multiword_ids(text: str) -> str: + """'Coastal Battery#2' -> 'CoastalBattery#2', run before + squash_enemy_names() (order doesn't actually matter, the two patterns + can't overlap: this one requires a literal '#'+digits, the Enemy one + requires no digits at all).""" + return _MULTIWORD_ID_RE.sub(lambda m: "".join(m.group(1).split()) + "#" + m.group(2), text) + + +# A named anchor can also be given inline, all on one line, rather than as +# its own block: "Listening Post#1 at K6 7:8 audio reports on:". RP-shaped +# same as everything else that resolves to a name + coord, searched for +# 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, +) + + +def parse_named_at_coord(text: str) -> list[dict]: + """Parse every '# at ' anchor into an RP-shaped + entry. Call after squash_multiword_ids(), so a multi-word name here + is already a single 'Name#id' token by the time this regex sees it.""" + 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: + continue + entries.append({ + "kind": "rp", "name": f"{word}#{_fix_id_digits(num)}", "type_word": None, "id": None, + "raw": m.group(0).strip(), "clues": [], "coord": coord, + }) + return entries + + _BLOCK_END_RE = re.compile(r"^[.\s]{1,6}$") _LEADING_NOISE_RE = re.compile(r"^[^A-Za-z]{1,3}(?=[A-Za-z])") @@ -253,6 +307,21 @@ _CLUE_INLINE_RE = re.compile( rf"(\d{{1,3}})\s*°?\s*,\s*([\d.]+)\s*k?m{_GAP}from\s+(\S+)", re.IGNORECASE ) _CLUE_BEARING_RE = re.compile(rf"Bearing\s*(\d{{1,3}})\s*°?{_GAP}from\s+(\S+)", re.IGNORECASE) + +# A listening post gives distance readings by ear, an approximate 8-point +# compass direction instead of a precise degree bearing: "Distance 6.28km +# South-East from Listening Post#1". Word order differs from every other +# clue shape too (the direction sits between the distance and 'from', +# there's no separate 'Bearing' keyword at all), so this needs its own +# pattern rather than reusing _CLUE_COMBINED_RE with a looser bearing group. +_COMPASS_BEARINGS = { + "NORTH": 0.0, "NORTHEAST": 45.0, "EAST": 90.0, "SOUTHEAST": 135.0, + "SOUTH": 180.0, "SOUTHWEST": 225.0, "WEST": 270.0, "NORTHWEST": 315.0, +} +_COMPASS_WORD_RE = r"(North(?:[-\s]?East|[-\s]?West)?|South(?:[-\s]?East|[-\s]?West)?|East|West)" +_CLUE_DISTANCE_COMPASS_RE = re.compile( + rf"Distance\s*([\d.]+)\s*k?m?\s*{_COMPASS_WORD_RE}{_GAP}from\s+(\S+)", re.IGNORECASE +) _CLUE_DISTANCE_RE = re.compile(rf"Distance\s*([\d.]+)\s*k?m?{_GAP}from\s+(\S+)", re.IGNORECASE) _TYPE_BY_SHORT = {t.short: t for t in TargetType} @@ -301,6 +370,9 @@ _CLUE_PATTERNS = ( (_CLUE_COMBINED_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))), (_CLUE_INLINE_RE, lambda m: (float(m.group(1)), float(m.group(2)), m.group(3))), (_CLUE_BEARING_RE, lambda m: (float(m.group(1)), None, m.group(2))), + (_CLUE_DISTANCE_COMPASS_RE, lambda m: ( + _COMPASS_BEARINGS[re.sub(r"[-\s]", "", m.group(2)).upper()], float(m.group(1)), m.group(3) + )), (_CLUE_DISTANCE_RE, lambda m: (None, float(m.group(1)), m.group(2))), ) @@ -330,7 +402,8 @@ def parse_clues_from_text(text: str) -> list[Clue]: """Parse every Bearing/Distance clue found in free-form text, used for manually-typed descriptions in the coord dialog, sharing the exact same clue grammar as the OCR'd intel blocks.""" - return _parse_all_clues(squash_enemy_names(text)) + text = strip_html(squash_bold_spans(text)) + return _parse_all_clues(squash_enemy_names(squash_multiword_ids(text))) def _resolve_target_type(type_word: str) -> TargetType | None: @@ -549,6 +622,42 @@ def parse_destroyed(text: str) -> set[tuple[TargetType, str]]: return destroyed +_BOLD_SPAN_RE = re.compile(r"(.*?)", re.IGNORECASE | re.DOTALL) +# Same two shapes squash_enemy_names()/squash_multiword_ids() look for, +# but anchored to fullmatch just one bold span's own content rather than +# searched for across the whole document. That's strictly safer: those +# two functions have to *guess* where a multi-word name ends using +# capitalization (and got that wrong once already, swallowing 'from' into +# a name it bordered), where here the closing tag itself is the +# actual boundary, nothing to guess. +_ENEMY_SPAN_RE = re.compile(r"Enemy(?:\s+[A-Za-z]+){1,4}$", re.IGNORECASE) +_MULTIWORD_ID_SPAN_RE = re.compile(r"([A-Za-z]+(?:\s+[A-Za-z]+){1,3})\s*#\s*(\d+)$") + + +def _squash_span_content(content: str) -> str: + if _ENEMY_SPAN_RE.match(content): + words = content.split() + return "Enemy#" + "".join(words[1:]) + multiword_id = _MULTIWORD_ID_SPAN_RE.match(content) + if multiword_id: + return "".join(multiword_id.group(1).split()) + "#" + multiword_id.group(2) + return content + + +def squash_bold_spans(text: str) -> str: + """Rich-text pastes wrap each atomic value/name in its own + '...' ('Enemy Signal Station', 'Listening + Post#1'), collapse a multi-word one down to our single-token + 'Type#id' shape using that tag boundary as ground truth, before the + tags themselves get stripped. Must run before strip_html(), it needs + the tags still there to know a span's extent. A no-op on plain OCR + text, which never has '' in it to begin with, squash_enemy_names()/ + squash_multiword_ids() (run afterward regardless) are what handle + that case, guessing from capitalization since there's no markup left + to lean on.""" + return _BOLD_SPAN_RE.sub(lambda m: "" + _squash_span_content(m.group(1)) + "", text) + + _HTML_BREAK_RE = re.compile(r"(?i)|") _HTML_TAG_RE = re.compile(r"<[^>]+>") @@ -581,7 +690,8 @@ class ParsedInfo: def parse_text(text: str) -> ParsedInfo: - text = squash_enemy_names(strip_html(text)) + text = strip_html(squash_bold_spans(text)) + text = squash_enemy_names(squash_multiword_ids(text)) info = ParsedInfo() for raw_line in text.splitlines(): @@ -624,6 +734,9 @@ def parse_text(text: str) -> ParsedInfo: for entry in parse_train_intel(text): info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"]) + for entry in parse_named_at_coord(text): + info.reference_points[entry["name"]] = (entry["raw"], entry["clues"], entry["coord"]) + info.destroyed = parse_destroyed(text) return info