stenodojo

stenodojo
git clone git@git.zachrice.app:repos/stenodojo.git
Log | Files | Refs

plover_engine.py (25450B)


      1 """Plover dictionary engine: loads JSON + Python dictionaries from plover.cfg."""
      2 
      3 import configparser
      4 import importlib
      5 import importlib.util
      6 import json
      7 import os
      8 import random
      9 import re
     10 import sys
     11 
     12 STENO_ORDER = "#STKPWHRAO*EUFRPBLGTSDZ"
     13 _DISPLAY_OVERRIDES = {"i": "I"}
     14 
     15 _CAP_COMMANDS = {
     16     # Old-style
     17     "{-|}": "cap_next",
     18     "{^}{-|}": "cap_next_attach",
     19     "{*-|}": "cap_retro",
     20     "{*<}": "upper_retro",
     21     "{<}": "upper_next",
     22     "{>}": "lower_next",
     23     "{*>}": "lower_retro",
     24     # New-style (standalone)
     25     "{:case:cap_first_word}": "cap_next",
     26     "{:case:upper_first_word}": "upper_next",
     27     "{:case:lower_first_char}": "lower_next",
     28     "{:retro_case:cap_first_word}": "cap_retro",
     29     "{:retro_case:upper_first_word}": "upper_retro",
     30     "{:retro_case:lower_first_char}": "lower_retro",
     31     # Compound: {} + command (space-aware cap)
     32     "{}{-|}": "cap_next",
     33     "{}{:case:cap_first_word}": "cap_next",
     34     # Compound: {^} + command (attach + cap)
     35     "{^}{:case:cap_first_word}": "cap_next_attach",
     36     "{^}{:case:upper_first_word}": "upper_next_attach",
     37 }
     38 
     39 
     40 class PloverEngine:
     41     def __init__(self, config_path=None):
     42         self.config_path = config_path
     43         self.config_dir = os.path.dirname(self.config_path) if self.config_path else ""
     44         self.stroke_to_word = {}
     45         self.word_to_strokes = {}
     46         self.phrase_to_strokes = {}
     47         self._phrase_display = {}
     48         self.punctuation_to_strokes = {}
     49         self.cap_strokes = {}
     50         self._practice_words = []
     51         self._single_stroke_words = []
     52         self._python_dicts = []
     53         self._stroke_priority = {}
     54         self._stroke_labels = {}
     55         self._stroke_context = {}
     56         self._ready = False
     57         self.load_error = None
     58 
     59     def _reset(self):
     60         self.stroke_to_word.clear()
     61         self.word_to_strokes.clear()
     62         self.phrase_to_strokes.clear()
     63         self._phrase_display.clear()
     64         self.punctuation_to_strokes.clear()
     65         self.cap_strokes.clear()
     66         self._practice_words.clear()
     67         self._single_stroke_words.clear()
     68         self._python_dicts.clear()
     69         self._stroke_priority.clear()
     70         self._stroke_labels.clear()
     71         self._stroke_context.clear()
     72         self._ready = False
     73         self.load_error = None
     74 
     75     def load(self):
     76         self._reset()
     77 
     78         if not self.config_path or not os.path.exists(self.config_path):
     79             self.load_error = f"Plover config not found: {self.config_path}"
     80             print(f"Warning: {self.load_error}")
     81             self._ready = True
     82             return
     83 
     84         config = configparser.ConfigParser()
     85         config.read(self.config_path)
     86 
     87         section = "System: English Stenotype"
     88         if not config.has_section(section):
     89             self.load_error = f"Section [{section}] not found in plover.cfg"
     90             print(f"Warning: {self.load_error}")
     91             self._ready = True
     92             return
     93 
     94         dicts_json = config.get(section, "dictionaries", fallback="[]")
     95         dict_entries = json.loads(dicts_json)
     96 
     97         json_count = 0
     98         py_count = 0
     99 
    100         for priority, entry in enumerate(dict_entries):
    101             if not entry.get("enabled", False):
    102                 continue
    103 
    104             path = entry["path"]
    105             full_path = os.path.join(self.config_dir, path)
    106 
    107             if path.endswith(".json"):
    108                 if self._load_json_dict(full_path, path, priority):
    109                     json_count += 1
    110             elif path.endswith(".py"):
    111                 if self._load_python_dict(full_path, path, priority):
    112                     py_count += 1
    113 
    114         print(f"  JSON dictionaries: {json_count}")
    115         print(f"  Python dictionaries: {py_count}")
    116         print(f"  Total stroke entries: {len(self.stroke_to_word)}")
    117 
    118         self._build_indexes()
    119         self._ready = True
    120 
    121     def load_from_uploaded(self, dict_dir, dict_entries):
    122         """Load dictionaries from user-uploaded files.
    123 
    124         dict_entries: list of dicts with keys: filename, enabled, dict_order
    125         """
    126         self._reset()
    127 
    128         if not os.path.isdir(dict_dir):
    129             self.load_error = "No dictionaries uploaded yet"
    130             self._ready = True
    131             return
    132 
    133         sorted_entries = sorted(dict_entries, key=lambda e: e.get("dict_order", 0))
    134         json_count = 0
    135         py_count = 0
    136 
    137         for priority, entry in enumerate(sorted_entries):
    138             if not entry.get("enabled", True):
    139                 continue
    140             filename = entry["filename"]
    141             full_path = os.path.join(dict_dir, filename)
    142 
    143             if filename.endswith(".json"):
    144                 if self._load_json_dict(full_path, filename, priority):
    145                     json_count += 1
    146             elif filename.endswith(".py"):
    147                 if self._load_python_dict(full_path, filename, priority):
    148                     py_count += 1
    149 
    150         print(f"  Uploaded JSON dictionaries: {json_count}")
    151         print(f"  Uploaded Python dictionaries: {py_count}")
    152         print(f"  Total stroke entries: {len(self.stroke_to_word)}")
    153 
    154         self._build_indexes()
    155         self._ready = True
    156 
    157     @staticmethod
    158     def parse_plover_config(cfg_content):
    159         """Parse plover.cfg content and return the ordered list of dictionary entries."""
    160         config = configparser.ConfigParser()
    161         config.read_string(cfg_content)
    162 
    163         section = "System: English Stenotype"
    164         if not config.has_section(section):
    165             return []
    166 
    167         dicts_json = config.get(section, "dictionaries", fallback="[]")
    168         raw = json.loads(dicts_json)
    169 
    170         result = []
    171         for i, entry in enumerate(raw):
    172             path = entry.get("path", "")
    173             filename = os.path.basename(path)
    174             if not filename:
    175                 continue
    176             result.append({
    177                 "filename": filename,
    178                 "original_path": path,
    179                 "enabled": entry.get("enabled", True),
    180                 "order": i,
    181             })
    182         return result
    183 
    184     def _load_json_dict(self, full_path, display_path, priority=0):
    185         if not os.path.exists(full_path):
    186             print(f"  Warning: not found: {display_path}")
    187             return False
    188         try:
    189             with open(full_path, "r", encoding="utf-8") as f:
    190                 data = json.load(f)
    191             count = 0
    192             for stroke, translation in data.items():
    193                 if stroke not in self.stroke_to_word:
    194                     self.stroke_to_word[stroke] = translation
    195                     self._stroke_priority[stroke] = priority
    196                 count += 1
    197             print(f"  Loaded: {display_path} ({count} entries)")
    198             return True
    199         except (json.JSONDecodeError, OSError) as e:
    200             print(f"  Warning: failed to load {display_path}: {e}")
    201             return False
    202 
    203     def _load_python_dict(self, full_path, display_path, priority=0):
    204         if not os.path.exists(full_path):
    205             print(f"  Warning: not found: {display_path}")
    206             return False
    207         try:
    208             dict_dir = os.path.dirname(full_path)
    209             module_name = os.path.splitext(os.path.basename(full_path))[0]
    210 
    211             if dict_dir not in sys.path:
    212                 sys.path.insert(0, dict_dir)
    213 
    214             safe_name = f"plover_pydict_{module_name.replace('-', '_')}"
    215             spec = importlib.util.spec_from_file_location(safe_name, full_path)
    216             mod = importlib.util.module_from_spec(spec)
    217             spec.loader.exec_module(mod)
    218 
    219             has_lookup = hasattr(mod, "lookup")
    220             has_reverse = hasattr(mod, "reverse_lookup")
    221             longest_key = getattr(mod, "LONGEST_KEY", 0)
    222 
    223             if has_lookup:
    224                 self._python_dicts.append({
    225                     "module": mod,
    226                     "name": module_name,
    227                     "path": display_path,
    228                     "has_reverse": has_reverse,
    229                     "longest_key": longest_key,
    230                     "priority": priority,
    231                 })
    232                 label = "lookup+reverse" if has_reverse else "lookup only"
    233                 print(f"  Loaded: {display_path} (python, {label})")
    234                 return True
    235             else:
    236                 print(f"  Skipped: {display_path} (no lookup function)")
    237                 return False
    238         except Exception as e:
    239             print(f"  Warning: failed to load {display_path}: {e}")
    240             return False
    241 
    242     def _build_indexes(self):
    243         raw = {}
    244         origcase = {}
    245         punct_raw = {}
    246         for stroke, translation in self.stroke_to_word.items():
    247             stripped = translation.strip()
    248             if _is_punctuation(stripped):
    249                 if stripped not in punct_raw:
    250                     punct_raw[stripped] = []
    251                 punct_raw[stripped].append(stroke)
    252                 continue
    253             plover_punct = _PLOVER_PUNCT.get(stripped)
    254             if plover_punct:
    255                 if plover_punct not in punct_raw:
    256                     punct_raw[plover_punct] = []
    257                 punct_raw[plover_punct].append(stroke)
    258                 continue
    259             cap_type = _CAP_COMMANDS.get(stripped)
    260             if cap_type:
    261                 if cap_type not in self.cap_strokes:
    262                     self.cap_strokes[cap_type] = []
    263                 self.cap_strokes[cap_type].append(stroke)
    264                 continue
    265             if not _is_practice_word(stripped):
    266                 continue
    267             key = stripped.lower()
    268             if key not in raw:
    269                 raw[key] = []
    270             if key != stripped and key not in origcase:
    271                 origcase[key] = stripped
    272             raw[key].append(stroke)
    273 
    274         prio = self._stroke_priority
    275         for word, strokes in raw.items():
    276             strokes.sort(key=lambda s: (prio.get(s, 999), s.count("/") + 1, len(s)))
    277             if " " in word:
    278                 self.phrase_to_strokes[word] = strokes
    279                 if word not in self._phrase_display:
    280                     self._phrase_display[word] = origcase.get(word, word)
    281             else:
    282                 self.word_to_strokes[word] = strokes
    283 
    284         self._scan_python_dicts_for_punctuation(punct_raw)
    285 
    286         for punct, strokes in punct_raw.items():
    287             strokes.sort(key=lambda s: (prio.get(s, 999), s.count("/") + 1, len(s)))
    288             self.punctuation_to_strokes[punct] = strokes
    289 
    290         for cap_type, strokes in self.cap_strokes.items():
    291             strokes.sort(key=lambda s: (prio.get(s, 999), s.count("/") + 1, len(s)))
    292 
    293         if self.cap_strokes:
    294             print(f"  Capitalization commands: {sum(len(v) for v in self.cap_strokes.values())}")
    295 
    296         self._practice_words = list(self.word_to_strokes.keys())
    297         self._single_stroke_words = [
    298             w for w, ss in self.word_to_strokes.items()
    299             if any("/" not in s for s in ss)
    300         ]
    301 
    302         print(f"  Practice words: {len(self._practice_words)}")
    303         print(f"  Phrases (from JSON): {len(self.phrase_to_strokes)}")
    304         print(f"  Punctuation entries: {len(self.punctuation_to_strokes)}")
    305         print(f"  Single-stroke words: {len(self._single_stroke_words)}")
    306 
    307     def _scan_python_dicts_for_punctuation(self, punct_raw):
    308         """Probe Python dicts to find strokes that produce punctuation."""
    309         target_chars = set(".,;:!?\"'()-[]{}…/")
    310         found = 0
    311         for pd in self._python_dicts:
    312             mod = pd["module"]
    313             sym_table = getattr(mod, "symbols", None)
    314             if isinstance(sym_table, dict):
    315                 attach_method = getattr(mod, "attachmentMethod", "space")
    316                 found += self._scan_symbol_table(
    317                     sym_table, target_chars, punct_raw,
    318                     pd["priority"], attach_method)
    319         if found:
    320             print(f"  Punctuation from Python dicts: {found}")
    321 
    322     @staticmethod
    323     def _emily_stroke_variants(starter, pattern, attachment_method):
    324         """Generate attachment/capitalization variants for emily-style strokes.
    325         Returns list of (stroke, label, context_tag) tuples."""
    326         def build(mid):
    327             if mid:
    328                 return starter + mid + pattern
    329             return starter + ("-" if pattern else "") + pattern
    330 
    331         if attachment_method == "space":
    332             return [
    333                 (build(""), None, "no_space"),
    334                 (build("O"), "spc after", "space_after"),
    335                 (build("O*"), "spc + cap", "space_cap"),
    336                 (build("AO"), "spc both", "space_both"),
    337             ]
    338         else:
    339             return [
    340                 (build(""), None, "space_both"),
    341                 (build("A"), "attach left", "space_after"),
    342                 (build("A*"), "attach + cap", "space_cap"),
    343                 (build("AO"), "no spc", "no_space"),
    344             ]
    345 
    346     def _scan_symbol_table(self, sym_table, target_chars, punct_raw,
    347                            priority, attachment_method):
    348         """Extract punctuation strokes from an emily-style symbols dict."""
    349         found = 0
    350         for starter, patterns in sym_table.items():
    351             if not isinstance(patterns, dict):
    352                 continue
    353             for pattern, variants in patterns.items():
    354                 if isinstance(variants, str):
    355                     variants = [variants]
    356                 if not isinstance(variants, list):
    357                     continue
    358                 char = variants[0] if variants else ""
    359                 if len(char) == 1 and char in target_chars:
    360                     for stroke, label, ctx in self._emily_stroke_variants(
    361                             starter, pattern, attachment_method):
    362                         if char not in punct_raw:
    363                             punct_raw[char] = []
    364                         if stroke not in punct_raw[char]:
    365                             punct_raw[char].append(stroke)
    366                             self._stroke_priority[stroke] = priority
    367                             self._stroke_context[stroke] = ctx
    368                             if label:
    369                                 self._stroke_labels[stroke] = label
    370                             found += 1
    371         return found
    372 
    373     def get_strokes(self, text):
    374         key = text.lower().strip()
    375         result = list(self.word_to_strokes.get(key, []))
    376         if not result:
    377             result = list(self.phrase_to_strokes.get(key, []))
    378         if not result:
    379             result = self._python_reverse_lookup(text.strip())
    380         if not result and key != text.strip():
    381             result = self._python_reverse_lookup(key)
    382         return result
    383 
    384     def _python_reverse_lookup(self, text):
    385         results = []
    386         for pd in self._python_dicts:
    387             if not pd["has_reverse"]:
    388                 continue
    389             try:
    390                 hits = pd["module"].reverse_lookup(text)
    391                 for stroke_tuple in hits:
    392                     if len(stroke_tuple) == 1:
    393                         results.append(stroke_tuple[0])
    394             except Exception:
    395                 pass
    396         return results
    397 
    398     def get_phrase_strokes(self, text):
    399         key = text.lower().strip()
    400         result = list(self.phrase_to_strokes.get(key, []))
    401         py_strokes = self._python_reverse_lookup(text.strip())
    402         if not py_strokes and key != text.strip():
    403             py_strokes = self._python_reverse_lookup(key)
    404         for s in py_strokes:
    405             if s not in result:
    406                 result.append(s)
    407         return result
    408 
    409     def detect_phrase_hints(self, words, current_index):
    410         if current_index >= len(words):
    411             return []
    412         hints = []
    413         max_len = min(8, len(words) - current_index + 1)
    414         for length in range(2, max_len):
    415             phrase = " ".join(words[current_index:current_index + length])
    416             strokes = self.get_phrase_strokes(phrase)
    417             if strokes:
    418                 single = [s for s in strokes if "/" not in s]
    419                 best = single[:5] if single else strokes[:5]
    420                 hints.append({
    421                     "phrase": phrase,
    422                     "words_covered": length,
    423                     "strokes": best,
    424                     "is_single_stroke": bool(single),
    425                     "keys": parse_stroke(best[0]) if best else [],
    426                 })
    427         hints.sort(key=lambda h: h["words_covered"], reverse=True)
    428         return hints
    429 
    430     def get_all_hints(self, word, context_words=None, context_index=None,
    431                       suppress_cap=False):
    432         bare, trailing_punct = _strip_punctuation(word)
    433         lookup_word = bare if bare else word
    434 
    435         strokes = self.get_strokes(lookup_word)
    436         all_hints = []
    437         for s in strokes:
    438             sc = s.count("/") + 1
    439             kc = sum(len(_parse_single_stroke(part)) for part in s.split("/"))
    440             all_hints.append({
    441                 "stroke": s,
    442                 "stroke_count": sc,
    443                 "key_count": kc,
    444                 "keys": parse_stroke(s),
    445             })
    446         all_hints.sort(key=lambda h: (h["stroke_count"], h["key_count"]))
    447 
    448         fingerspell = not all_hints and len(lookup_word) > 0
    449         punct_hints = []
    450         for p in trailing_punct:
    451             ps = self.punctuation_to_strokes.get(p, [])
    452             if ps:
    453                 punct_hints.append({"char": p, "stroke": ps[0]})
    454 
    455         phrase_hints = []
    456         if context_words and context_index is not None:
    457             phrase_hints = self.detect_phrase_hints(context_words, context_index)
    458 
    459         cap_hints = []
    460         if (not suppress_cap and lookup_word and lookup_word[0].isupper()
    461                 and lookup_word not in _DISPLAY_OVERRIDES.values()):
    462             for cap_type in ("cap_next", "cap_next_attach", "cap_retro"):
    463                 ss = self.cap_strokes.get(cap_type, [])
    464                 if ss:
    465                     cap_hints.append({"type": cap_type, "stroke": ss[0]})
    466         if (not suppress_cap and lookup_word and lookup_word.isupper()
    467                 and len(lookup_word) > 1):
    468             for cap_type in ("upper_next", "upper_next_attach", "upper_retro"):
    469                 ss = self.cap_strokes.get(cap_type, [])
    470                 if ss:
    471                     cap_hints.append({"type": cap_type, "stroke": ss[0]})
    472 
    473         return {
    474             "word_hints": all_hints[:10],
    475             "phrase_hints": phrase_hints,
    476             "fingerspell": fingerspell,
    477             "punct_hints": punct_hints,
    478             "cap_hints": cap_hints,
    479         }
    480 
    481     def get_punct_hints(self, char, limit=8,
    482                         space_after=False, cap_next=False):
    483         """Build hint items for a punctuation character, sorted by context."""
    484         strokes = self.punctuation_to_strokes.get(char, [])
    485         if space_after and cap_next:
    486             target = "space_cap"
    487         elif space_after:
    488             target = "space_after"
    489         else:
    490             target = "no_space"
    491 
    492         hints = []
    493         for s in strokes:
    494             sc = s.count("/") + 1
    495             kc = sum(len(_parse_single_stroke(part)) for part in s.split("/"))
    496             ctx = self._stroke_context.get(s)
    497             h = {
    498                 "stroke": s,
    499                 "stroke_count": sc,
    500                 "key_count": kc,
    501                 "keys": parse_stroke(s),
    502             }
    503             label = self._stroke_labels.get(s)
    504             if label:
    505                 h["label"] = label
    506             match_rank = 0 if ctx == target else 1
    507             hints.append((match_rank, sc, kc, h))
    508 
    509         hints.sort(key=lambda t: (t[0], t[1], t[2]))
    510         return [h for _, _, _, h in hints[:limit]]
    511 
    512     def word_exists(self, word):
    513         key = word.lower().strip()
    514         if key in self.word_to_strokes or key in self.phrase_to_strokes:
    515             return True
    516         bare, _ = _strip_punctuation(key)
    517         if bare and bare != key:
    518             return bare in self.word_to_strokes or bare in self.phrase_to_strokes
    519         return False
    520 
    521     def display_form(self, word):
    522         """Return the canonical display form (e.g. 'i' -> 'I')."""
    523         return _DISPLAY_OVERRIDES.get(word.lower().strip(), word)
    524 
    525     @property
    526     def practice_words(self):
    527         return list(self._practice_words)
    528 
    529     @property
    530     def single_stroke_words(self):
    531         return list(self._single_stroke_words)
    532 
    533     @property
    534     def all_phrases(self):
    535         result = []
    536         for key in self.phrase_to_strokes:
    537             display = self._phrase_display.get(key, key)
    538             if _phrase_has_proper_noun(display):
    539                 continue
    540             result.append(display)
    541         return result
    542 
    543     def generate_phrasing_phrases(self, count=200):
    544         """Generate random phrases from Python phrasing dicts (e.g. jeff-phrasing)."""
    545         _V1 = ["", "A", "O", "AO"]
    546         _STAR = ["", "*"]
    547         _V2 = ["", "E", "U", "EU"]
    548         _F = ["", "F"]
    549 
    550         all_phrases = []
    551         for pd in self._python_dicts:
    552             mod = pd["module"]
    553             starters = getattr(mod, "STARTERS", None)
    554             enders = getattr(mod, "ENDERS", None)
    555             simple_starters = getattr(mod, "SIMPLE_STARTERS", None)
    556             simple_pronouns = getattr(mod, "SIMPLE_PRONOUNS", None)
    557             if not starters or not enders:
    558                 continue
    559 
    560             starter_keys = list(starters.keys())
    561             ender_keys = list(enders.keys())
    562             ss_keys = list(simple_starters.keys()) if simple_starters else []
    563             sp_keys = list(simple_pronouns.keys()) if simple_pronouns else []
    564 
    565             seen = set()
    566             attempts = 0
    567             while len(all_phrases) < count and attempts < count * 30:
    568                 attempts += 1
    569                 use_simple = ss_keys and sp_keys and random.random() < 0.3
    570                 if use_simple:
    571                     stroke = (random.choice(ss_keys)
    572                               + random.choice(sp_keys)
    573                               + random.choice(_F)
    574                               + random.choice(ender_keys))
    575                 else:
    576                     stroke = (random.choice(starter_keys)
    577                               + random.choice(_V1)
    578                               + random.choice(_STAR)
    579                               + random.choice(_V2)
    580                               + random.choice(_F)
    581                               + random.choice(ender_keys))
    582                 try:
    583                     out = mod.lookup((stroke,))
    584                 except (KeyError, Exception):
    585                     continue
    586                 out = out.strip()
    587                 if not out or " " not in out:
    588                     continue
    589                 wc = len(out.split())
    590                 if wc < 2 or wc > 5:
    591                     continue
    592                 if out in seen:
    593                     continue
    594                 seen.add(out)
    595                 all_phrases.append(out)
    596 
    597         return all_phrases
    598 
    599 
    600 _PUNCTUATION_CHARS = set(".,;:!?\"'()-[]{}…/")
    601 
    602 _PLOVER_PUNCT = {
    603     # Old-style
    604     "{.}": ".", "{,}": ",", "{!}": "!", "{?}": "?",
    605     "{;}": ";", "{:}": ":",
    606     # New-style
    607     "{:stop:.}": ".", "{:stop:?}": "?", "{:stop:!}": "!",
    608     "{:comma:,}": ",", "{:comma:;}": ";", "{:comma::}": ":",
    609 }
    610 
    611 
    612 def _strip_punctuation(word):
    613     """Split trailing punctuation from a word. Returns (bare_word, [punct_chars])."""
    614     trailing = []
    615     while word and word[-1] in _PUNCTUATION_CHARS:
    616         trailing.append(word[-1])
    617         word = word[:-1]
    618     trailing.reverse()
    619     return word, trailing
    620 
    621 
    622 def _is_punctuation(text):
    623     return len(text) == 1 and text in _PUNCTUATION_CHARS
    624 
    625 
    626 def _phrase_has_proper_noun(phrase):
    627     """True if phrase contains capitalized words other than 'I' and its contractions."""
    628     return any(
    629         w[0].isupper() and w != "I" and not w.startswith("I'")
    630         for w in phrase.split() if w
    631     )
    632 
    633 
    634 def _is_practice_word(translation):
    635     if not translation or not translation.strip():
    636         return False
    637     if "{" in translation or "}" in translation:
    638         return False
    639     if not re.match(r"^[a-zA-Z][a-zA-Z '\-]*$", translation):
    640         return False
    641     stripped = translation.strip()
    642     if len(stripped) > 1 and stripped.replace(" ", "").replace("-", "").replace("'", "").isupper():
    643         return False
    644     return True
    645 
    646 
    647 def parse_stroke(stroke):
    648     if "/" in stroke:
    649         return [_parse_single_stroke(s) for s in stroke.split("/")]
    650     return [_parse_single_stroke(stroke)]
    651 
    652 
    653 def _parse_single_stroke(stroke):
    654     keys = []
    655     if stroke.startswith("#"):
    656         keys.append("#")
    657         stroke = stroke[1:]
    658     if not stroke:
    659         return keys
    660 
    661     left_keys = "STKPWHR"
    662     center_keys = "AO*EU"
    663     right_keys = "FRPBLGTSDZ"
    664 
    665     if "-" in stroke:
    666         left_part, right_part = stroke.split("-", 1)
    667         for ch in left_part:
    668             if ch in left_keys:
    669                 keys.append(f"{ch}-")
    670             elif ch in center_keys:
    671                 keys.append(ch)
    672         for ch in right_part:
    673             if ch in right_keys:
    674                 keys.append(f"-{ch}")
    675             elif ch in center_keys:
    676                 keys.append(ch)
    677     else:
    678         pos = 0
    679         for ch in stroke:
    680             for i in range(pos, len(STENO_ORDER)):
    681                 if STENO_ORDER[i] == ch:
    682                     pos = i + 1
    683                     if i == 0:
    684                         keys.append("#")
    685                     elif i <= 7:
    686                         keys.append(f"{ch}-")
    687                     elif i <= 12:
    688                         keys.append(ch)
    689                     else:
    690                         keys.append(f"-{ch}")
    691                     break
    692     return keys