From 40387d64b0e851b3b9f19a663d5a19a0c8ab268e Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sun, 9 Aug 2026 12:32:49 +0200 Subject: [PATCH] OCR: parse ad-hoc 'Enemy X Y' installations as a new target type 'Enemy Signal Station:' and its ilk are named in plain English rather than the usual Type#N shape, and get referenced the same way elsewhere ('Bearing 034 from Enemy Signal Station'), breaking two assumptions everywhere else in this module: headers/references are a single whitespace-free token, and an id is digit-shaped. squash_enemy_names() collapses 'Enemy' + up to 4 Title Case words that follow it into one token in our own id shape ('Enemy#SignalStation') before anything else parses the text, so every existing from- clue pattern and the named-header matcher keep working unmodified. Wired into both parse_text() (OCR/clipboard) and parse_clues_from_text() (manual description tab). Two follow-on fixes this surfaced: _clean_reference() previously assumed a named reference's id is always digit-shaped and would truncate 'Enemy#SignalStation' down to 'Enemy#Sig' via _fix_id_digits's letter-to-digit mapping; and the letter-id header check needed to run *before* _NAMED_HEADER_RE, whose digit class overlaps plain letters (S/B/Z/G/O/I/L) and would otherwise partial-match and mangle the id first. New TargetType.ENEMY carries these. Verified end to end (including the solver resolving the cross-references between them) through both parse_text() and the real _merge_targets() app flow. --- src/fenigma/app.py | 42 ++++++++++++++++++++++----- src/fenigma/models.py | 9 ++++-- src/fenigma/ocr.py | 67 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 107 insertions(+), 11 deletions(-) diff --git a/src/fenigma/app.py b/src/fenigma/app.py index 8336c7c..ccbb8d5 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -79,7 +79,8 @@ def _row( input_btn.connect("clicked", lambda _b: on_input()) box.append(input_btn) - shot_btn = Gtk.Button(icon_name="insert-image-symbolic", tooltip_text="Set coords from screenshot") + shot_btn = Gtk.Button(icon_name="insert-image-symbolic", + tooltip_text="Set coords from clipboard (screenshot or pasted text)") shot_btn.connect("clicked", lambda _b: on_screenshot()) box.append(shot_btn) @@ -221,13 +222,13 @@ class MainWindow(Adw.ApplicationWindow): header.pack_start(clear_btn) clip_btn = Gtk.Button(icon_name="edit-paste-symbolic") - clip_btn.set_tooltip_text("Fetch screenshot from clipboard (Ctrl+P)") + clip_btn.set_tooltip_text("Fetch screenshot or text from clipboard (Ctrl+P)") clip_btn.connect("clicked", lambda _b: self._fetch_clipboard()) header.pack_start(clip_btn) self._watch_btn = Gtk.ToggleButton(icon_name="media-playback-start-symbolic") self._watch_btn.set_tooltip_text( - "Auto-watch clipboard: apply new screenshots as soon as they're copied" + "Auto-watch clipboard: apply new screenshots or pasted text as soon as they're copied" ) self._watch_btn.connect("toggled", self._on_toggle_clipboard_watch) header.pack_start(self._watch_btn) @@ -371,10 +372,35 @@ class MainWindow(Adw.ApplicationWindow): self._run_ocr_from_clipboard(self._merge_all) def _run_ocr_from_clipboard(self, on_parsed) -> None: - """Read the clipboard image, OCR it, and call on_parsed(ParsedInfo).""" + """Read the clipboard and call on_parsed(ParsedInfo). Prefers plain/rich + text (pasted intel, no screenshot needed) when the clipboard has any; + falls back to reading an image and running it through OCR otherwise.""" clipboard = Gdk.Display.get_default().get_clipboard() + formats = clipboard.get_formats() + if formats is not None and formats.contain_gtype(str): + clipboard.read_text_async(None, lambda cb, res: self._on_clipboard_text_ready(res, on_parsed)) + return clipboard.read_texture_async(None, lambda cb, res: self._on_ocr_texture_ready(res, on_parsed)) + def _on_clipboard_text_ready(self, result: Gio.AsyncResult, on_parsed) -> None: + clipboard = Gdk.Display.get_default().get_clipboard() + try: + text = clipboard.read_text_finish(result) + except GLib.Error as exc: + self.toast(f"Clipboard read failed ({exc.message}).") + return + if not text or not text.strip(): + self.toast("Clipboard text is empty. Copy some intel text or a screenshot first.") + return + + try: + info = ocr.parse_text(text) + except Exception as exc: # parsing hiccups shouldn't crash the app + self.toast(f"Parsing failed: {exc}") + return + + on_parsed(info) + def _on_ocr_texture_ready(self, result: Gio.AsyncResult, on_parsed) -> None: clipboard = Gdk.Display.get_default().get_clipboard() try: @@ -410,12 +436,14 @@ class MainWindow(Adw.ApplicationWindow): clipboard.disconnect(self._clipboard_watch_handler) self._clipboard_watch_handler = None btn.set_icon_name("media-playback-start-symbolic") - btn.set_tooltip_text("Auto-watch clipboard: apply new screenshots as soon as they're copied") + btn.set_tooltip_text( + "Auto-watch clipboard: apply new screenshots or pasted text as soon as they're copied" + ) def _on_clipboard_changed(self, clipboard: Gdk.Clipboard) -> None: formats = clipboard.get_formats() - if formats is None or not formats.contain_gtype(Gdk.Texture): - return # not an image (e.g. text copied elsewhere), ignore quietly + if formats is None or not (formats.contain_gtype(Gdk.Texture) or formats.contain_gtype(str)): + return # neither an image nor text, ignore quietly self._run_ocr_from_clipboard(self._merge_all) def _on_window_destroy(self, *_a) -> None: diff --git a/src/fenigma/models.py b/src/fenigma/models.py index 392966b..97ea9ff 100644 --- a/src/fenigma/models.py +++ b/src/fenigma/models.py @@ -36,13 +36,18 @@ NATO_ALPHABET = [ class TargetType(Enum): + UNKNOWN = "Target" # generic contact, spotted but not yet identified; default choice SUPPLY_CACHE = "Supply Cache" - UNKNOWN = "Target" # generic contact, spotted but not yet identified FDC = "FDC" # Fire Direction Center, coordinates enemy counter-battery fire INFANTRY = "Infantry" # hostile ground troops MECHANIZED = "Mechanized" # hostile armored/vehicle unit HOSTILE_ARTILLERY = "Hostile Artillery" HOSTILE_TANK = "Hostile Tank" + PILLBOX = "Pillbox" # armoured emplacement, fixed position + 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 + # spaces stripped, see ocr.py's squash_enemy_names() STRIKE = "Strike" # a planned impact point, not an enemy contact @property @@ -308,7 +313,7 @@ class Target: def name(self) -> str: return f"{self.type.short}#{self.id}" - _AP_DEFAULT_TYPES = (TargetType.FDC, TargetType.SUPPLY_CACHE) + _AP_DEFAULT_TYPES = (TargetType.FDC, TargetType.SUPPLY_CACHE, TargetType.PILLBOX) @property def effective_shell(self) -> Shell: diff --git a/src/fenigma/ocr.py b/src/fenigma/ocr.py index 0cfabf4..02b3961 100644 --- a/src/fenigma/ocr.py +++ b/src/fenigma/ocr.py @@ -22,6 +22,7 @@ characters don't drop an otherwise-good line. from __future__ import annotations import difflib +import html import re from dataclasses import dataclass, field @@ -205,6 +206,31 @@ _RP_HEADER_RE = re.compile(r"Reference\s+Point\s+([A-Za-z][\w-]*)\s*:?", re.IGNO # 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*(.*)$") + +# Ad-hoc enemy installations are named in plain English rather than given a +# Type#N id ("Enemy Signal Station:", "Bearing 034 from Enemy Signal +# Station"), which breaks two assumptions everywhere else in this module: +# headers/references are always a single whitespace-free token, and an id +# is always digit-shaped. squash_enemy_names() fixes both up front by +# collapsing 'Enemy' + however many Title Case words follow it into one +# token in our own 'Type#id' shape ('Enemy#SignalStation'), before +# anything else tries to parse the text, so every from- clue pattern +# (which just captures \S+) and _NAMED_HEADER_RE both work unmodified +# except NAMED_HEADER_RE's id group is digit-only, so a second header +# regex below handles the now-squashed, letter-only id. +_ENEMY_NAME_RE = re.compile(r"\bEnemy(?:[ \t]+[A-Z][a-zA-Z]*){1,4}\b") +_ENEMY_HEADER_RE = re.compile(r"^(Enemy)#([A-Za-z]+)\s*:?\s*(.*)$", re.IGNORECASE) + + +def squash_enemy_names(text: str) -> str: + """'Enemy Signal Station' -> 'Enemy#SignalStation', anywhere it + appears, header or reference alike. The word-separator inside a name + is deliberately [ \\t]+, not \\s+: it must not cross a newline, or a + header right at the end of a line ('...Enemy Signal Station') would + swallow the next line's 'Bearing ...' clue into the same 'name'.""" + return _ENEMY_NAME_RE.sub(lambda m: "Enemy#" + "".join(m.group(0).split()[1:]), text) + + _BLOCK_END_RE = re.compile(r"^[.\s]{1,6}$") _LEADING_NOISE_RE = re.compile(r"^[^A-Za-z]{1,3}(?=[A-Za-z])") @@ -244,10 +270,18 @@ def _clean_reference(raw: str) -> str: references ('Spotter#1', 'AmmoCache#2') get their digit part fixed up and their separator normalized to '#'; plain word references ('Alpha') are left untouched, don't run digit-fixing over them or real letters - like the 'l' in 'Alpha' get corrupted into '1'.""" + like the 'l' in 'Alpha' get corrupted into '1'. 'Enemy#SignalStation' + (squash_enemy_names() already normalized the separator, and its id is + letters, not digits) is passed through as-is rather than falling into + the digit-fixing path below, which would otherwise stop at the first + letter outside _DIGIT_CLASS and truncate it (e.g. down to 'Enemy#Sig').""" token = re.match(r"\S+", raw.strip()) token = token.group(0) if token else raw.strip() + enemy = _ENEMY_HEADER_RE.match(token) + if enemy: + return f"{enemy.group(1)}#{enemy.group(2)}" + named = _REF_NAMED_RE.match(token) if named: word, num = named.groups() @@ -296,7 +330,7 @@ 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(text) + return _parse_all_clues(squash_enemy_names(text)) def _resolve_target_type(type_word: str) -> TargetType | None: @@ -372,6 +406,19 @@ def parse_intel_blocks(text: str) -> list[dict]: "raw": [line], "clues": []} continue + # Tried before _NAMED_HEADER_RE: its digit class overlaps plain + # letters (S/B/Z/G/O/I/L), so on a squashed 'Enemy#SignalStation' + # header it would otherwise partial-match ('Sig' -> digit-fixed + # into the bogus id '516') before this more specific check ever + # gets a look. + enemy_m = next((m for c in candidates if (m := _ENEMY_HEADER_RE.match(c))), None) + if enemy_m: + flush() + type_word, letter_id, inline = enemy_m.groups() + current = {"kind": "named", "name": f"{type_word}#{letter_id}", "type_word": type_word, + "id": letter_id, "raw": [line], "clues": []} + continue + named_m = next((m for c in candidates if (m := _NAMED_HEADER_RE.match(c))), None) if named_m: flush() @@ -502,6 +549,21 @@ def parse_destroyed(text: str) -> set[tuple[TargetType, str]]: return destroyed +_HTML_BREAK_RE = re.compile(r"(?i)|") +_HTML_TAG_RE = re.compile(r"<[^>]+>") + + +def strip_html(text: str) -> str: + """Drop markup from rich-text clipboard pastes (e.g. '145°'), + leaving plain text the rest of the pipeline can parse. Block-ish + closing tags become newlines first so pastes that rely on markup + rather than real line breaks don't get run together; harmless no-op + on plain OCR text with no '<' in it.""" + text = _HTML_BREAK_RE.sub("\n", text) + text = _HTML_TAG_RE.sub("", text) + return html.unescape(text) + + @dataclass class ParsedInfo: nest_coord: Coord | None = None @@ -519,6 +581,7 @@ class ParsedInfo: def parse_text(text: str) -> ParsedInfo: + text = squash_enemy_names(strip_html(text)) info = ParsedInfo() for raw_line in text.splitlines():