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.
- meikipop/__init__.py +5 -0
- meikipop/config/__init__.py +0 -0
- meikipop/config/config.py +110 -0
- meikipop/dictionary/__init__.py +0 -0
- meikipop/dictionary/customdict.py +160 -0
- meikipop/dictionary/deconjugator.py +109 -0
- meikipop/dictionary/lookup.py +317 -0
- meikipop/gui/__init__.py +0 -0
- meikipop/gui/input.py +218 -0
- meikipop/gui/magpie_manager.py +149 -0
- meikipop/gui/popup.py +470 -0
- meikipop/gui/region_selector.py +115 -0
- meikipop/gui/settings_dialog.py +458 -0
- meikipop/gui/tray.py +208 -0
- meikipop/main.py +131 -0
- meikipop/ocr/__init__.py +0 -0
- meikipop/ocr/hit_scan.py +123 -0
- meikipop/ocr/interface.py +61 -0
- meikipop/ocr/ocr.py +142 -0
- meikipop/ocr/providers/__init__.py +0 -0
- meikipop/ocr/providers/dummy/__init__.py +2 -0
- meikipop/ocr/providers/dummy/provider.py +166 -0
- meikipop/ocr/providers/glensv2/__init__.py +1 -0
- meikipop/ocr/providers/glensv2/lens_betterproto.py +1238 -0
- meikipop/ocr/providers/glensv2/provider.py +114 -0
- meikipop/ocr/providers/meikiocr/__init__.py +2 -0
- meikipop/ocr/providers/meikiocr/provider.py +120 -0
- meikipop/ocr/providers/owocr/__init__.py +2 -0
- meikipop/ocr/providers/owocr/provider.py +138 -0
- meikipop/ocr/providers/postprocessing.py +194 -0
- meikipop/ocr/providers/screenai/__init__.py +1 -0
- meikipop/ocr/providers/screenai/chrome_screen_ai_pb2.py +52 -0
- meikipop/ocr/providers/screenai/provider.py +212 -0
- meikipop/ocr/providers/screenai/view_hierarchy_pb2.py +58 -0
- meikipop/resources/icon.ico +0 -0
- meikipop/resources/icon.inactive.ico +0 -0
- meikipop/screenshot/__init__.py +0 -0
- meikipop/screenshot/screenmanager.py +135 -0
- meikipop/screenshot/wayland_mss_shim.py +382 -0
- meikipop/scripts/__init__.py +1 -0
- meikipop/scripts/build_dictionary.py +550 -0
- meikipop/scripts/deconjugator.json +1223 -0
- meikipop/scripts/import_yomitan_dict_html.py +593 -0
- meikipop/scripts/import_yomitan_dict_text.py +372 -0
- meikipop/utils/__init__.py +0 -0
- meikipop/utils/lastest_queue.py +25 -0
- meikipop/utils/logger.py +32 -0
- meikipop/utils/paths.py +55 -0
- meikipop-2.0.0.dist-info/METADATA +188 -0
- meikipop-2.0.0.dist-info/RECORD +54 -0
- meikipop-2.0.0.dist-info/WHEEL +5 -0
- meikipop-2.0.0.dist-info/entry_points.txt +7 -0
- meikipop-2.0.0.dist-info/licenses/LICENSE +674 -0
- meikipop-2.0.0.dist-info/top_level.txt +1 -0
meikipop/__init__.py
ADDED
|
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
|