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-<ref>
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.
This commit is contained in:
parent
82b3fca0a0
commit
40387d64b0
@ -79,7 +79,8 @@ def _row(
|
|||||||
input_btn.connect("clicked", lambda _b: on_input())
|
input_btn.connect("clicked", lambda _b: on_input())
|
||||||
box.append(input_btn)
|
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())
|
shot_btn.connect("clicked", lambda _b: on_screenshot())
|
||||||
box.append(shot_btn)
|
box.append(shot_btn)
|
||||||
|
|
||||||
@ -221,13 +222,13 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
header.pack_start(clear_btn)
|
header.pack_start(clear_btn)
|
||||||
|
|
||||||
clip_btn = Gtk.Button(icon_name="edit-paste-symbolic")
|
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())
|
clip_btn.connect("clicked", lambda _b: self._fetch_clipboard())
|
||||||
header.pack_start(clip_btn)
|
header.pack_start(clip_btn)
|
||||||
|
|
||||||
self._watch_btn = Gtk.ToggleButton(icon_name="media-playback-start-symbolic")
|
self._watch_btn = Gtk.ToggleButton(icon_name="media-playback-start-symbolic")
|
||||||
self._watch_btn.set_tooltip_text(
|
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)
|
self._watch_btn.connect("toggled", self._on_toggle_clipboard_watch)
|
||||||
header.pack_start(self._watch_btn)
|
header.pack_start(self._watch_btn)
|
||||||
@ -371,10 +372,35 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
self._run_ocr_from_clipboard(self._merge_all)
|
self._run_ocr_from_clipboard(self._merge_all)
|
||||||
|
|
||||||
def _run_ocr_from_clipboard(self, on_parsed) -> None:
|
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()
|
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))
|
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:
|
def _on_ocr_texture_ready(self, result: Gio.AsyncResult, on_parsed) -> None:
|
||||||
clipboard = Gdk.Display.get_default().get_clipboard()
|
clipboard = Gdk.Display.get_default().get_clipboard()
|
||||||
try:
|
try:
|
||||||
@ -410,12 +436,14 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
clipboard.disconnect(self._clipboard_watch_handler)
|
clipboard.disconnect(self._clipboard_watch_handler)
|
||||||
self._clipboard_watch_handler = None
|
self._clipboard_watch_handler = None
|
||||||
btn.set_icon_name("media-playback-start-symbolic")
|
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:
|
def _on_clipboard_changed(self, clipboard: Gdk.Clipboard) -> None:
|
||||||
formats = clipboard.get_formats()
|
formats = clipboard.get_formats()
|
||||||
if formats is None or not formats.contain_gtype(Gdk.Texture):
|
if formats is None or not (formats.contain_gtype(Gdk.Texture) or formats.contain_gtype(str)):
|
||||||
return # not an image (e.g. text copied elsewhere), ignore quietly
|
return # neither an image nor text, ignore quietly
|
||||||
self._run_ocr_from_clipboard(self._merge_all)
|
self._run_ocr_from_clipboard(self._merge_all)
|
||||||
|
|
||||||
def _on_window_destroy(self, *_a) -> None:
|
def _on_window_destroy(self, *_a) -> None:
|
||||||
|
|||||||
@ -36,13 +36,18 @@ NATO_ALPHABET = [
|
|||||||
|
|
||||||
|
|
||||||
class TargetType(Enum):
|
class TargetType(Enum):
|
||||||
|
UNKNOWN = "Target" # generic contact, spotted but not yet identified; default choice
|
||||||
SUPPLY_CACHE = "Supply Cache"
|
SUPPLY_CACHE = "Supply Cache"
|
||||||
UNKNOWN = "Target" # generic contact, spotted but not yet identified
|
|
||||||
FDC = "FDC" # Fire Direction Center, coordinates enemy counter-battery fire
|
FDC = "FDC" # Fire Direction Center, coordinates enemy counter-battery fire
|
||||||
INFANTRY = "Infantry" # hostile ground troops
|
INFANTRY = "Infantry" # hostile ground troops
|
||||||
MECHANIZED = "Mechanized" # hostile armored/vehicle unit
|
MECHANIZED = "Mechanized" # hostile armored/vehicle unit
|
||||||
HOSTILE_ARTILLERY = "Hostile Artillery"
|
HOSTILE_ARTILLERY = "Hostile Artillery"
|
||||||
HOSTILE_TANK = "Hostile Tank"
|
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
|
STRIKE = "Strike" # a planned impact point, not an enemy contact
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -308,7 +313,7 @@ class Target:
|
|||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return f"{self.type.short}#{self.id}"
|
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
|
@property
|
||||||
def effective_shell(self) -> Shell:
|
def effective_shell(self) -> Shell:
|
||||||
|
|||||||
@ -22,6 +22,7 @@ characters don't drop an otherwise-good line.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import difflib
|
import difflib
|
||||||
|
import html
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
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,
|
# every observed header: '#', a misread substitute, ...) rules that out,
|
||||||
# and also stops a bare "Word 094" clue line (space only) from matching.
|
# 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*(.*)$")
|
_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-<ref> 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}$")
|
_BLOCK_END_RE = re.compile(r"^[.\s]{1,6}$")
|
||||||
_LEADING_NOISE_RE = re.compile(r"^[^A-Za-z]{1,3}(?=[A-Za-z])")
|
_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
|
references ('Spotter#1', 'AmmoCache#2') get their digit part fixed up
|
||||||
and their separator normalized to '#'; plain word references ('Alpha')
|
and their separator normalized to '#'; plain word references ('Alpha')
|
||||||
are left untouched, don't run digit-fixing over them or real letters
|
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 = re.match(r"\S+", raw.strip())
|
||||||
token = token.group(0) if token else 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)
|
named = _REF_NAMED_RE.match(token)
|
||||||
if named:
|
if named:
|
||||||
word, num = named.groups()
|
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
|
"""Parse every Bearing/Distance clue found in free-form text, used
|
||||||
for manually-typed descriptions in the coord dialog, sharing the
|
for manually-typed descriptions in the coord dialog, sharing the
|
||||||
exact same clue grammar as the OCR'd intel blocks."""
|
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:
|
def _resolve_target_type(type_word: str) -> TargetType | None:
|
||||||
@ -372,6 +406,19 @@ def parse_intel_blocks(text: str) -> list[dict]:
|
|||||||
"raw": [line], "clues": []}
|
"raw": [line], "clues": []}
|
||||||
continue
|
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)
|
named_m = next((m for c in candidates if (m := _NAMED_HEADER_RE.match(c))), None)
|
||||||
if named_m:
|
if named_m:
|
||||||
flush()
|
flush()
|
||||||
@ -502,6 +549,21 @@ def parse_destroyed(text: str) -> set[tuple[TargetType, str]]:
|
|||||||
return destroyed
|
return destroyed
|
||||||
|
|
||||||
|
|
||||||
|
_HTML_BREAK_RE = re.compile(r"(?i)<br\s*/?>|</(?:p|div|li|tr)>")
|
||||||
|
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_html(text: str) -> str:
|
||||||
|
"""Drop markup from rich-text clipboard pastes (e.g. '<b>145</b>°'),
|
||||||
|
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
|
@dataclass
|
||||||
class ParsedInfo:
|
class ParsedInfo:
|
||||||
nest_coord: Coord | None = None
|
nest_coord: Coord | None = None
|
||||||
@ -519,6 +581,7 @@ class ParsedInfo:
|
|||||||
|
|
||||||
|
|
||||||
def parse_text(text: str) -> ParsedInfo:
|
def parse_text(text: str) -> ParsedInfo:
|
||||||
|
text = squash_enemy_names(strip_html(text))
|
||||||
info = ParsedInfo()
|
info = ParsedInfo()
|
||||||
|
|
||||||
for raw_line in text.splitlines():
|
for raw_line in text.splitlines():
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user