meikipop 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. meikipop/__init__.py +5 -0
  2. meikipop/config/__init__.py +0 -0
  3. meikipop/config/config.py +110 -0
  4. meikipop/dictionary/__init__.py +0 -0
  5. meikipop/dictionary/customdict.py +160 -0
  6. meikipop/dictionary/deconjugator.py +109 -0
  7. meikipop/dictionary/lookup.py +317 -0
  8. meikipop/gui/__init__.py +0 -0
  9. meikipop/gui/input.py +218 -0
  10. meikipop/gui/magpie_manager.py +149 -0
  11. meikipop/gui/popup.py +470 -0
  12. meikipop/gui/region_selector.py +115 -0
  13. meikipop/gui/settings_dialog.py +458 -0
  14. meikipop/gui/tray.py +208 -0
  15. meikipop/main.py +131 -0
  16. meikipop/ocr/__init__.py +0 -0
  17. meikipop/ocr/hit_scan.py +123 -0
  18. meikipop/ocr/interface.py +61 -0
  19. meikipop/ocr/ocr.py +142 -0
  20. meikipop/ocr/providers/__init__.py +0 -0
  21. meikipop/ocr/providers/dummy/__init__.py +2 -0
  22. meikipop/ocr/providers/dummy/provider.py +166 -0
  23. meikipop/ocr/providers/glensv2/__init__.py +1 -0
  24. meikipop/ocr/providers/glensv2/lens_betterproto.py +1238 -0
  25. meikipop/ocr/providers/glensv2/provider.py +114 -0
  26. meikipop/ocr/providers/meikiocr/__init__.py +2 -0
  27. meikipop/ocr/providers/meikiocr/provider.py +120 -0
  28. meikipop/ocr/providers/owocr/__init__.py +2 -0
  29. meikipop/ocr/providers/owocr/provider.py +138 -0
  30. meikipop/ocr/providers/postprocessing.py +194 -0
  31. meikipop/ocr/providers/screenai/__init__.py +1 -0
  32. meikipop/ocr/providers/screenai/chrome_screen_ai_pb2.py +52 -0
  33. meikipop/ocr/providers/screenai/provider.py +212 -0
  34. meikipop/ocr/providers/screenai/view_hierarchy_pb2.py +58 -0
  35. meikipop/resources/icon.ico +0 -0
  36. meikipop/resources/icon.inactive.ico +0 -0
  37. meikipop/screenshot/__init__.py +0 -0
  38. meikipop/screenshot/screenmanager.py +135 -0
  39. meikipop/screenshot/wayland_mss_shim.py +382 -0
  40. meikipop/scripts/__init__.py +1 -0
  41. meikipop/scripts/build_dictionary.py +550 -0
  42. meikipop/scripts/deconjugator.json +1223 -0
  43. meikipop/scripts/import_yomitan_dict_html.py +593 -0
  44. meikipop/scripts/import_yomitan_dict_text.py +372 -0
  45. meikipop/utils/__init__.py +0 -0
  46. meikipop/utils/lastest_queue.py +25 -0
  47. meikipop/utils/logger.py +32 -0
  48. meikipop/utils/paths.py +55 -0
  49. meikipop-2.0.0.dist-info/METADATA +188 -0
  50. meikipop-2.0.0.dist-info/RECORD +54 -0
  51. meikipop-2.0.0.dist-info/WHEEL +5 -0
  52. meikipop-2.0.0.dist-info/entry_points.txt +7 -0
  53. meikipop-2.0.0.dist-info/licenses/LICENSE +674 -0
  54. meikipop-2.0.0.dist-info/top_level.txt +1 -0
meikipop/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """meikipop - Universal Japanese OCR popup dictionary"""
2
+
3
+ __version__ = "2.0.0"
4
+ __author__ = "rtr46"
5
+ __license__ = "GPL-3.0"
File without changes
@@ -0,0 +1,110 @@
1
+ # meikipop/config/config.py
2
+ import configparser
3
+ import logging
4
+ import os
5
+ import sys
6
+
7
+ from meikipop.utils.paths import paths
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ APP_NAME = "meikipop"
12
+ APP_VERSION = "2.0.0"
13
+ MAX_DICT_ENTRIES = 10
14
+ IS_LINUX = sys.platform.startswith('linux')
15
+ IS_WINDOWS = sys.platform.startswith('win')
16
+ IS_MACOS = sys.platform.startswith('darwin')
17
+ # todo should we use this instead?: IS_WAYLAND = IS_LINUX and bool(os.environ.get('WAYLAND_DISPLAY'))
18
+ IS_WAYLAND = IS_LINUX and os.environ.get('XDG_SESSION_TYPE', '').lower() == 'wayland'
19
+
20
+ # Force xwayland so windows can pop up in arbitary locations
21
+ if IS_WAYLAND:
22
+ os.environ['QT_QPA_PLATFORM'] = 'xcb'
23
+
24
+ CONFIG_PATH = paths.config_path
25
+ DICT_PATH = paths.dictionary_path
26
+
27
+ class Config:
28
+ _instance = None
29
+
30
+ _SCHEMA = {
31
+ 'Settings': {
32
+ 'hotkey': 'shift',
33
+ 'scan_region': 'region',
34
+ 'max_lookup_length': 25,
35
+ 'glens_low_bandwidth': False,
36
+ 'ocr_provider': 'meikiocr (local)',
37
+ 'auto_scan_mode': True,
38
+ 'auto_scan_mode_lookups_without_hotkey': True,
39
+ 'auto_scan_interval_seconds': 0.5,
40
+ 'auto_scan_on_mouse_move': True,
41
+ 'magpie_compatibility': True
42
+ },
43
+ 'Theme': {
44
+ 'theme_name': 'Nazeka',
45
+ 'font_family': '',
46
+ 'font_size_definitions': 14,
47
+ 'font_size_header': 18,
48
+ 'compact_mode': True,
49
+ 'show_all_glosses': False,
50
+ 'show_deconjugation': False,
51
+ 'show_pos': False,
52
+ 'show_tags': False,
53
+ 'show_frequency': False,
54
+ 'show_kanji': True,
55
+ 'show_examples': True,
56
+ 'show_components': True,
57
+ 'color_background': '#2E2E2E',
58
+ 'color_foreground': '#F0F0F0',
59
+ 'color_highlight_word': '#88D8FF',
60
+ 'color_highlight_reading': '#90EE90',
61
+ 'background_opacity': 245,
62
+ 'popup_position_mode': 'visual_novel_mode'
63
+ }
64
+ }
65
+
66
+ def __new__(cls):
67
+ if not cls._instance:
68
+ cls._instance = super().__new__(cls)
69
+ cls._instance._load()
70
+ return cls._instance
71
+
72
+ def _load(self):
73
+ parser = configparser.ConfigParser()
74
+ found = parser.read(CONFIG_PATH, encoding='utf-8')
75
+
76
+ for section, settings in self._SCHEMA.items():
77
+ for key, default in settings.items():
78
+ if parser.has_option(section, key):
79
+ if isinstance(default, bool):
80
+ val = parser.getboolean(section, key)
81
+ elif isinstance(default, int):
82
+ val = parser.getint(section, key)
83
+ elif isinstance(default, float):
84
+ val = parser.getfloat(section, key)
85
+ else:
86
+ val = parser.get(section, key)
87
+ else:
88
+ val = default
89
+ setattr(self, key, val)
90
+
91
+ self.is_enabled = True
92
+ if found:
93
+ logger.info(f"Configuration loaded from '{CONFIG_PATH}'.")
94
+ else:
95
+ logger.info(f"No configuration found at '{CONFIG_PATH}'. A new one will be created with default values.")
96
+
97
+ def save(self):
98
+ parser = configparser.ConfigParser()
99
+ for section, settings in self._SCHEMA.items():
100
+ parser.add_section(section)
101
+ for key in settings:
102
+ val = getattr(self, key)
103
+ parser.set(section, key, str(val).lower() if isinstance(val, bool) else str(val))
104
+
105
+ with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
106
+ parser.write(f)
107
+ logger.info(f"Settings saved to '{CONFIG_PATH}'.")
108
+
109
+
110
+ config = Config()
File without changes
@@ -0,0 +1,160 @@
1
+ # customdict.py
2
+ import logging
3
+ import pickle
4
+ import time
5
+ import urllib.request
6
+ import zipfile
7
+ import io
8
+ from collections import defaultdict
9
+
10
+ from meikipop.utils.paths import paths
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ DICT_URL = "https://github.com/rtr46/meikipop/releases/download/dictionary-latest/dictionary.zip"
15
+
16
+ DEFAULT_FREQ = 999_999
17
+
18
+ # MapEntry tuple field indices. value: (written_form, reading, freq, entry_id)
19
+ WRITTEN_FORM_INDEX = 0
20
+ READING_INDEX = 1
21
+ FREQUENCY_INDEX = 2
22
+ ENTRY_ID_INDEX = 3
23
+
24
+ class Dictionary:
25
+ def __init__(self):
26
+ # Core entries: {entry_id: [sense, ...]}
27
+ # Each sense: {'glosses': [...], 'pos': [...], 'misc': [...]}
28
+ self.entries: dict[int, list] = {}
29
+
30
+ # lookup_map: surface_form → [(written_form, reading_or_None, freq, entry_id), ...]
31
+ self.lookup_map: dict[str, list] = defaultdict(list)
32
+
33
+ # Kanji character entries from kanjidic2: {character: {...}}
34
+ self.kanji_entries: dict[str, dict] = {}
35
+
36
+ # Deconjugation rules consumed by Deconjugator at runtime
37
+ self.deconjugator_rules: list[dict] = []
38
+
39
+ self._is_loaded = False
40
+
41
+ def load_dictionary(self, file_path: str) -> bool:
42
+ if self._is_loaded:
43
+ return True
44
+ logger.info(f"Loading dictionary from '{file_path}'")
45
+ start = time.perf_counter()
46
+ try:
47
+ with open(file_path, 'rb') as f:
48
+ data = pickle.load(f)
49
+ self.entries = data['entries']
50
+ self.lookup_map = data['lookup_map']
51
+ self.kanji_entries = data.get('kanji_entries', {})
52
+ self.deconjugator_rules = data.get('deconjugator_rules', [])
53
+ self._is_loaded = True
54
+ n_refs = sum(len(v) for v in self.lookup_map.values())
55
+ logger.info(
56
+ f"Dictionary loaded in {time.perf_counter() - start:.2f}s"
57
+ f"({len(self.entries)} core entries, {n_refs} lookup refs)"
58
+ )
59
+ self._validate()
60
+ return True
61
+ except FileNotFoundError:
62
+ logger.warning(f"Dictionary file not found. Trying download...")
63
+ if self._download_dictionary():
64
+ return self.load_dictionary(file_path) # retry once after download
65
+ logger.error(
66
+ "Dictionary could not be downloaded. "
67
+ "You can build it manually by running 'meikipop build-dict'."
68
+ )
69
+ return False
70
+ except Exception as e:
71
+ logger.error(f"Failed to load dictionary: {e}")
72
+ return False
73
+
74
+ def _download_dictionary(self) -> bool:
75
+ try:
76
+ with urllib.request.urlopen(DICT_URL) as response:
77
+ data = response.read()
78
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
79
+ zf.extract("dictionary.pkl", path=paths.data_dir)
80
+ logger.info("Dictionary downloaded successfully.")
81
+ return True
82
+ except Exception as e:
83
+ logger.error(f"Failed to download dictionary: {e}")
84
+ return False
85
+
86
+
87
+ def _validate(self):
88
+ """
89
+ Scan the loaded dictionary for structural invariants and log warnings
90
+ for any violations found. Never raises — validation is advisory only.
91
+
92
+ Invariants checked:
93
+ - Every map entry tuple has exactly 4 elements
94
+ - written_form is a non-empty str or None (None is valid for kana-only)
95
+ - reading is a str or None
96
+ - freq is an int
97
+ - entry_id exists in self.entries
98
+ - A map entry reached via a kanji-containing key must not have
99
+ written_form=None (that would render as an invisible entry)
100
+ """
101
+ issues = 0
102
+ missing_entry_ids = set()
103
+
104
+ for surface, me_list in self.lookup_map.items():
105
+ surface_has_kanji = any(0x4E00 <= ord(c) <= 0x9FFF for c in surface)
106
+ for me in me_list:
107
+ if len(me) != 4:
108
+ logger.warning(
109
+ f"Malformed map entry under key '{surface}': "
110
+ f"expected 4 fields, got {len(me)} — {me!r}"
111
+ )
112
+ issues += 1
113
+ continue
114
+
115
+ wf, rd, freq, entry_id = me
116
+
117
+ if wf is not None and not isinstance(wf, str):
118
+ logger.warning(
119
+ f"Map entry under '{surface}': written_form is {type(wf).__name__} "
120
+ f"(expected str or None) — entry_id={entry_id}"
121
+ )
122
+ issues += 1
123
+
124
+ if rd is not None and not isinstance(rd, str):
125
+ logger.warning(
126
+ f"Map entry under '{surface}': reading is {type(rd).__name__} "
127
+ f"(expected str or None) — entry_id={entry_id}"
128
+ )
129
+ issues += 1
130
+
131
+ if not isinstance(freq, int):
132
+ logger.warning(
133
+ f"Map entry under '{surface}': freq is {type(freq).__name__} "
134
+ f"(expected int) — entry_id={entry_id}"
135
+ )
136
+ issues += 1
137
+
138
+ if surface_has_kanji and wf is None:
139
+ logger.warning(
140
+ f"Map entry under kanji key '{surface}' has written_form=None "
141
+ f"(entry will display incorrectly) — entry_id={entry_id}"
142
+ )
143
+ issues += 1
144
+
145
+ if entry_id not in self.entries:
146
+ missing_entry_ids.add(entry_id)
147
+ issues += 1
148
+
149
+ if missing_entry_ids:
150
+ logger.warning(
151
+ f"{len(missing_entry_ids)} entry ID(s) referenced in lookup_map "
152
+ f"have no matching core entry — first few: "
153
+ f"{sorted(missing_entry_ids)[:5]}"
154
+ )
155
+
156
+ if issues == 0:
157
+ logger.info("Dictionary validation passed with no issues.")
158
+ else:
159
+ logger.warning(f"Dictionary validation found {issues} issue(s) — "
160
+ f"some entries may display incorrectly.")
@@ -0,0 +1,109 @@
1
+ # deconjugator.py
2
+ from dataclasses import dataclass, field
3
+ from typing import Set
4
+
5
+ MAX_DECONJ_ITERATIONS = 10
6
+
7
+ @dataclass(frozen=True)
8
+ class Form:
9
+ text: str
10
+ process: tuple = field(default_factory=tuple)
11
+ tags: tuple = field(default_factory=tuple)
12
+
13
+ def __repr__(self):
14
+ return f"Form(text='{self.text}', process={self.process}, tags={self.tags})"
15
+
16
+ class Deconjugator:
17
+ def __init__(self, rules: list[dict]):
18
+ self.rules = [r for r in rules if isinstance(r, dict)]
19
+
20
+ def deconjugate(self, text: str) -> Set[Form]:
21
+ clean_text = text.strip()
22
+ if not clean_text:
23
+ return set()
24
+
25
+ processed: Set[Form] = set()
26
+ novel: Set[Form] = {Form(text=clean_text)}
27
+
28
+ iteration = 0
29
+ while novel:
30
+ iteration += 1
31
+ if iteration > MAX_DECONJ_ITERATIONS: break
32
+
33
+ new_novel: Set[Form] = set()
34
+ for form in novel:
35
+ for rule in self.rules:
36
+ rule_type = rule.get('type')
37
+ if not rule_type or rule_type == 'substitution': continue
38
+
39
+ if rule_type == 'onlyfinalrule' and form.tags: continue
40
+ if rule_type == 'neverfinalrule' and not form.tags: continue
41
+
42
+ new_forms = self._apply_rule(form, rule)
43
+ if new_forms:
44
+ for f in new_forms:
45
+ if f not in processed and f not in novel and f not in new_novel:
46
+ new_novel.add(f)
47
+
48
+ processed.update(novel)
49
+ novel = new_novel
50
+
51
+ processed.add(Form(text=clean_text))
52
+ return processed
53
+
54
+ def _apply_rule(self, form: Form, rule: dict) -> Set[Form] | None:
55
+ if 'dec_end' not in rule or 'con_end' not in rule:
56
+ return None
57
+
58
+ dec_ends = rule['dec_end'] if isinstance(rule['dec_end'], list) else [rule['dec_end']]
59
+ con_ends = rule['con_end'] if isinstance(rule['con_end'], list) else [rule['con_end']]
60
+
61
+ con_tags_from_rule = rule.get('con_tag')
62
+ dec_tags_from_rule = rule.get('dec_tag')
63
+
64
+ con_tags = [con_tags_from_rule] if con_tags_from_rule and not isinstance(con_tags_from_rule,
65
+ list) else con_tags_from_rule
66
+ dec_tags = [dec_tags_from_rule] if dec_tags_from_rule and not isinstance(dec_tags_from_rule,
67
+ list) else dec_tags_from_rule
68
+
69
+ max_len = 1
70
+ list_source = next((lst for lst in [dec_ends, con_ends, dec_tags, con_tags] if isinstance(lst, list)), None)
71
+ if list_source: max_len = len(list_source)
72
+
73
+ results = set()
74
+
75
+ for i in range(max_len):
76
+ con_end = con_ends[i % len(con_ends)] if con_ends else ""
77
+ con_tag = con_tags[i % len(con_tags)] if con_tags else None
78
+ dec_end = dec_ends[i % len(dec_ends)] if dec_ends else ""
79
+ dec_tag = dec_tags[i % len(dec_tags)] if dec_tags else None
80
+
81
+ suffix_match = form.text.endswith(con_end)
82
+
83
+ current_form_tag = form.tags[-1] if form.tags else None
84
+ tag_match = False
85
+ is_starter_type = rule.get('type') in ['stdrule', 'rewriterule', 'onlyfinalrule', 'contextrule']
86
+
87
+ if not form.tags and is_starter_type:
88
+ tag_match = True
89
+ elif form.tags:
90
+ tag_match = (current_form_tag == con_tag)
91
+
92
+ if not suffix_match or not tag_match:
93
+ continue
94
+
95
+ if rule.get('type') == 'rewriterule' and form.text != con_end:
96
+ continue
97
+
98
+ new_text = form.text[:-len(con_end)] + dec_end if con_end else form.text + dec_end
99
+ new_process = form.process + (rule.get('detail', ''),)
100
+
101
+ if form.tags:
102
+ new_tags = form.tags[:-1] + (dec_tag,) if dec_tag is not None else form.tags[:-1]
103
+ else:
104
+ new_tags = (dec_tag,) if dec_tag is not None else ()
105
+
106
+ new_form_to_add = Form(text=new_text, process=new_process, tags=new_tags)
107
+ results.add(new_form_to_add)
108
+
109
+ return results if results else None