textrun 0.1.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.
textrun/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """textrun - Generalized Wayland-compatible text reader with OCR and TTS."""
2
+
3
+ __version__ = "0.1.0"
textrun/__main__.py ADDED
@@ -0,0 +1,194 @@
1
+ """CLI entry point for textrun."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from textrun import __version__
11
+ from textrun.config import detect_profile, load_config
12
+ from textrun.pipeline import Pipeline
13
+
14
+
15
+ def setup_logging(verbose: bool = False) -> None:
16
+ level = logging.DEBUG if verbose else logging.INFO
17
+ logging.basicConfig(
18
+ level=level,
19
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
20
+ datefmt="%H:%M:%S",
21
+ )
22
+ # Third-party libs probe multiple backends internally and log every
23
+ # failed probe (with tracebacks) at DEBUG. That is noise for us.
24
+ for name in ("pyscreenshot", "easyprocess", "PIL", "pyautogui", "pyscreeze"):
25
+ logging.getLogger(name).setLevel(logging.WARNING)
26
+
27
+
28
+ def parse_region(value: str) -> tuple[int, int, int, int]:
29
+ """Parse 'x,y,w,h' into a tuple of ints."""
30
+ parts = value.split(",")
31
+ if len(parts) != 4:
32
+ raise argparse.ArgumentTypeError(f"Region must be x,y,w,h (got {value!r})")
33
+ try:
34
+ return int(parts[0]), int(parts[1]), int(parts[2]), int(parts[3])
35
+ except ValueError:
36
+ raise argparse.ArgumentTypeError(
37
+ f"Region values must be integers (got {value!r})"
38
+ )
39
+
40
+
41
+ def build_parser() -> argparse.ArgumentParser:
42
+ parser = argparse.ArgumentParser(
43
+ prog="textrun",
44
+ description="Generalized text reader with OCR and TTS",
45
+ )
46
+ parser.add_argument(
47
+ "--version", action="version", version=f"%(prog)s {__version__}"
48
+ )
49
+ parser.add_argument(
50
+ "-v",
51
+ "--verbose",
52
+ action="store_true",
53
+ help="Enable debug logging",
54
+ )
55
+ parser.add_argument(
56
+ "-c",
57
+ "--config",
58
+ type=Path,
59
+ default=None,
60
+ help="Path to config file (default: ~/.config/textrun/config.toml)",
61
+ )
62
+ parser.add_argument(
63
+ "-p",
64
+ "--profile",
65
+ default=None,
66
+ help="Profile to use (default: auto-detect session type)",
67
+ )
68
+ parser.add_argument(
69
+ "--list-profiles",
70
+ action="store_true",
71
+ help="List available profiles and exit",
72
+ )
73
+ parser.add_argument(
74
+ "--gui",
75
+ action="store_true",
76
+ help="Launch the configuration GUI",
77
+ )
78
+ parser.add_argument(
79
+ "--shortcuts",
80
+ action="store_true",
81
+ help=("Daemon-like: register the global shortcuts and wait, no window shown"),
82
+ )
83
+ parser.add_argument(
84
+ "--install-desktop",
85
+ action="store_true",
86
+ help=(
87
+ "Install .desktop entry and icon so global shortcuts register "
88
+ "under 'textrun' instead of the launching terminal"
89
+ ),
90
+ )
91
+ parser.add_argument(
92
+ "--region",
93
+ type=parse_region,
94
+ default=None,
95
+ metavar="X,Y,W,H",
96
+ help=(
97
+ "Screen region to capture in real screen pixels "
98
+ "(e.g. 100,200,800,600). Note: the GUI saves logical coords plus "
99
+ "screen sizes and scales automatically; CLI coords are used as-is."
100
+ ),
101
+ )
102
+ parser.add_argument(
103
+ "--no-tts",
104
+ action="store_true",
105
+ help="Disable text-to-speech output",
106
+ )
107
+ parser.add_argument(
108
+ "--scroll-amount",
109
+ type=int,
110
+ default=None,
111
+ help="Override scroll amount (wheel clicks)",
112
+ )
113
+ parser.add_argument(
114
+ "--max-scrolls",
115
+ type=int,
116
+ default=None,
117
+ help="Override max scroll attempts",
118
+ )
119
+ parser.add_argument(
120
+ "-o",
121
+ "--output",
122
+ type=Path,
123
+ default=None,
124
+ help="Save extracted text to file instead of stdout",
125
+ )
126
+ return parser
127
+
128
+
129
+ def main(argv: list[str] | None = None) -> None:
130
+ parser = build_parser()
131
+ args = parser.parse_args(argv)
132
+ setup_logging(args.verbose)
133
+ profile = args.profile or detect_profile()
134
+
135
+ if args.gui or args.shortcuts:
136
+ try:
137
+ from textrun.gui.main_window import run_gui, run_shortcuts_daemon
138
+ except ImportError as exc: # pragma: no cover - broken install only
139
+ print(f"GUI unavailable (broken install?): {exc}", file=sys.stderr)
140
+ sys.exit(1)
141
+ config = load_config(args.config, profile=profile)
142
+ if args.shortcuts:
143
+ run_shortcuts_daemon(config)
144
+ else:
145
+ run_gui(config)
146
+ sys.exit(0)
147
+
148
+ if args.list_profiles:
149
+ cfg = load_config(args.config, profile="global")
150
+ for p in cfg.list_profiles():
151
+ print(p)
152
+ sys.exit(0)
153
+
154
+ if args.install_desktop:
155
+ from textrun.desktop import install_desktop_files
156
+
157
+ for p in install_desktop_files():
158
+ print(f"Installed: {p}")
159
+ print(
160
+ "Launch textrun from your app menu so global shortcuts "
161
+ "register under 'textrun' instead of your terminal."
162
+ )
163
+ sys.exit(0)
164
+
165
+ overrides: dict = {}
166
+ if args.no_tts:
167
+ overrides["tts_enabled"] = False
168
+ if args.scroll_amount is not None:
169
+ overrides["scroll_amount"] = args.scroll_amount
170
+ if args.max_scrolls is not None:
171
+ overrides["max_scroll_attempts"] = args.max_scrolls
172
+ if args.region is not None:
173
+ x, y, w, h = args.region
174
+ overrides["region"] = {"x": x, "y": y, "width": w, "height": h}
175
+
176
+ config = load_config(args.config, profile=profile)
177
+ if overrides:
178
+ config._profile_data.update(overrides)
179
+
180
+ logger = logging.getLogger("textrun")
181
+ logger.info("Using profile: %s", config.profile)
182
+
183
+ pipeline = Pipeline(config)
184
+ text = pipeline.run()
185
+
186
+ if args.output:
187
+ args.output.write_text(text, encoding="utf-8")
188
+ logger.info("Text saved to %s", args.output)
189
+ else:
190
+ print(text)
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()
textrun/adapters.py ADDED
@@ -0,0 +1,176 @@
1
+ """Adapter functions for stateful libraries that need initialization.
2
+
3
+ These are referenced from config as type="adapter" backends.
4
+ Each adapter is a simple function that takes a context dict
5
+ and returns a result string (or raises on failure).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import threading
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Cached library instances, keyed by thread (COM objects on Windows are
17
+ # apartment-threaded and must not be shared across threads). Reusing the
18
+ # instance avoids per-chunk init cost during live chunked playback.
19
+ _speechd_clients: dict[int, Any] = {}
20
+ _pyttsx3_engines: dict[int, Any] = {}
21
+
22
+
23
+ def _drop_cached(cache: dict, key: int) -> None:
24
+ cache.pop(key, None)
25
+
26
+
27
+ def speechd_say(context: dict[str, Any]) -> str:
28
+ """Speak text using speech-dispatcher."""
29
+ text = context.get("text", "")
30
+ if not text:
31
+ return ""
32
+
33
+ import speechd
34
+
35
+ key = threading.get_ident()
36
+ client = _speechd_clients.get(key)
37
+ if client is None:
38
+ client = speechd.Client()
39
+ _speechd_clients[key] = client
40
+
41
+ try:
42
+ client.set_output_module(context.get("speechd_module", "pico"))
43
+ client.set_language(context.get("language", "eng"))
44
+ client.speak(text)
45
+ except Exception as exc:
46
+ _drop_cached(_speechd_clients, key)
47
+ try:
48
+ client.close()
49
+ except Exception:
50
+ pass
51
+ raise RuntimeError(f"speech-dispatcher failed: {exc}") from exc
52
+
53
+ return text
54
+
55
+
56
+ def pyttsx3_say(context: dict[str, Any]) -> str:
57
+ """Speak text using pyttsx3 (wraps system TTS engine)."""
58
+ text = context.get("text", "")
59
+ if not text:
60
+ return ""
61
+
62
+ import pyttsx3
63
+
64
+ key = threading.get_ident()
65
+ engine = _pyttsx3_engines.get(key)
66
+ if engine is None:
67
+ engine = pyttsx3.init()
68
+ _pyttsx3_engines[key] = engine
69
+ rate = context.get("pyttsx3_rate")
70
+ if rate is not None:
71
+ engine.setProperty("rate", rate)
72
+ volume = context.get("pyttsx3_volume")
73
+ if volume is not None:
74
+ engine.setProperty("volume", volume)
75
+
76
+ try:
77
+ engine.say(text)
78
+ engine.runAndWait()
79
+ except Exception as exc:
80
+ _drop_cached(_pyttsx3_engines, key)
81
+ raise RuntimeError(f"pyttsx3 failed: {exc}") from exc
82
+
83
+ return text
84
+
85
+
86
+ def playsound3_play(context: dict[str, Any]) -> str:
87
+ """Play an audio file using playsound3."""
88
+ path = context.get("audio_path", "")
89
+ if not path:
90
+ raise RuntimeError("No audio_path provided")
91
+
92
+ from playsound3 import playsound
93
+
94
+ playsound(path)
95
+ return path
96
+
97
+
98
+ def crop_to_region(
99
+ path: str,
100
+ region: dict[str, Any],
101
+ screen_size: dict[str, Any] | None = None,
102
+ config_monitors: list | None = None,
103
+ ) -> str:
104
+ """Crop the image at *path* to *region* in place.
105
+
106
+ Region coords are logical and global (GUI). Monitor layout comes from
107
+ live providers (wlpdisplays/screeninfo/xrandr), then config-saved
108
+ monitors, then the legacy single-scale screen_size dict; with no info
109
+ at all coords are taken as-is. Skips cropping when the image already
110
+ matches the target size (backend cropped natively).
111
+ """
112
+ from PIL import Image
113
+
114
+ from textrun.monitors import get_monitors, region_crop_rect, single_from_screen_size
115
+
116
+ img = Image.open(path)
117
+ mons = get_monitors(config_monitors=config_monitors)
118
+ if mons is None and screen_size:
119
+ # Legacy aggregate info: missing real dims come from the capture
120
+ info = dict(screen_size)
121
+ if not info.get("real_width"):
122
+ info["real_width"] = img.width
123
+ if not info.get("real_height"):
124
+ info["real_height"] = img.height
125
+ mons = single_from_screen_size(info)
126
+
127
+ if mons:
128
+ x, y, w, h = region_crop_rect(region, mons)
129
+ else:
130
+ x = int(region["x"])
131
+ y = int(region["y"])
132
+ w = int(region["width"])
133
+ h = int(region["height"])
134
+
135
+ if abs(img.width - w) <= 2 and abs(img.height - h) <= 2:
136
+ return path # already region-sized
137
+
138
+ img.crop((x, y, x + w, y + h)).save(path)
139
+ return path
140
+
141
+
142
+ def pyscreenshot_capture(context: dict[str, Any]) -> str:
143
+ """Capture a screenshot using pyscreenshot. Supports region if set in context."""
144
+ output_path = context.get("output_path", "/tmp/textrun/screenshot.png")
145
+ region = context.get("region")
146
+
147
+ import pyscreenshot as ImageGrab
148
+
149
+ # Grab the full screen, then crop with PIL. Using bbox directly can be
150
+ # unreliable on Wayland (positioning ignored, cutout centered).
151
+ img = ImageGrab.grab()
152
+ img.save(output_path)
153
+ if region:
154
+ crop_to_region(output_path, region, context.get("screen_size"))
155
+ return output_path
156
+
157
+
158
+ def pyautogui_scroll(context: dict[str, Any]) -> str:
159
+ """Scroll using pyautogui."""
160
+ amount = int(context.get("scroll_amount", 3))
161
+
162
+ import pyautogui
163
+
164
+ pyautogui.scroll(amount)
165
+ return f"scrolled {amount}"
166
+
167
+
168
+ def pyautogui_screenshot(context: dict[str, Any]) -> str:
169
+ """Take a screenshot using pyautogui."""
170
+ output_path = context.get("output_path", "/tmp/textrun/screenshot.png")
171
+
172
+ import pyautogui
173
+
174
+ img = pyautogui.screenshot()
175
+ img.save(output_path)
176
+ return output_path
@@ -0,0 +1 @@
1
+ """Backend orchestrators for textrun."""
@@ -0,0 +1,34 @@
1
+ """Audio playback backend orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from textrun.runner import BackendResult, run_chain
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def play(
14
+ config: dict[str, Any],
15
+ context: dict[str, Any],
16
+ skip: set[str] | None = None,
17
+ ) -> BackendResult:
18
+ """Play an audio file using the configured backend chain.
19
+
20
+ Args:
21
+ config: The 'audio' section from the profile config.
22
+ context: Runtime context with audio_path, etc.
23
+ skip: Optional set of backend names to skip (failed earlier in this run).
24
+
25
+ Returns:
26
+ BackendResult indicating success/failure.
27
+ """
28
+ chain = config.get("chain", [])
29
+ backends = config.get("backends", {})
30
+
31
+ if not chain:
32
+ return BackendResult(success=False, error="No audio backends configured")
33
+
34
+ return run_chain(chain, backends, context, skip)
@@ -0,0 +1,34 @@
1
+ """OCR backend orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from textrun.runner import BackendResult, run_chain
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def extract_text(
14
+ config: dict[str, Any],
15
+ context: dict[str, Any],
16
+ skip: set[str] | None = None,
17
+ ) -> BackendResult:
18
+ """Extract text from an image using the configured backend chain.
19
+
20
+ Args:
21
+ config: The 'ocr' section from the profile config.
22
+ context: Runtime context with image_path, language, etc.
23
+ skip: Optional set of backend names to skip (failed earlier in this run).
24
+
25
+ Returns:
26
+ BackendResult with output containing extracted text.
27
+ """
28
+ chain = config.get("chain", [])
29
+ backends = config.get("backends", {})
30
+
31
+ if not chain:
32
+ return BackendResult(success=False, error="No OCR backends configured")
33
+
34
+ return run_chain(chain, backends, context, skip)
@@ -0,0 +1,44 @@
1
+ """Screenshot backend orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from textrun.runner import BackendResult, run_chain
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def capture(
15
+ config: dict[str, Any],
16
+ context: dict[str, Any],
17
+ skip: set[str] | None = None,
18
+ ) -> BackendResult:
19
+ """Capture a screenshot using the configured backend chain.
20
+
21
+ Args:
22
+ config: The 'screenshot' section from the profile config.
23
+ context: Runtime context with output_path, etc.
24
+ skip: Optional set of backend names to skip (failed earlier in this run).
25
+
26
+ Returns:
27
+ BackendResult with output containing the screenshot path.
28
+ """
29
+ chain = config.get("chain", [])
30
+ backends = config.get("backends", {})
31
+
32
+ if not chain:
33
+ return BackendResult(success=False, error="No screenshot backends configured")
34
+
35
+ output_path = context.get("output_path", "/tmp/textrun/screenshot.png")
36
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
37
+
38
+ ctx = {**context, "output_path": output_path}
39
+ result = run_chain(chain, backends, ctx, skip)
40
+
41
+ if result.success and not result.output:
42
+ result.output = output_path
43
+
44
+ return result
@@ -0,0 +1,34 @@
1
+ """Scroll backend orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from textrun.runner import BackendResult, run_chain
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def scroll(
14
+ config: dict[str, Any],
15
+ context: dict[str, Any],
16
+ skip: set[str] | None = None,
17
+ ) -> BackendResult:
18
+ """Scroll down using the configured backend chain.
19
+
20
+ Args:
21
+ config: The 'scroll' section from the profile config.
22
+ context: Runtime context with scroll_amount, etc.
23
+ skip: Optional set of backend names to skip (failed earlier in this run).
24
+
25
+ Returns:
26
+ BackendResult indicating success/failure.
27
+ """
28
+ chain = config.get("chain", [])
29
+ backends = config.get("backends", {})
30
+
31
+ if not chain:
32
+ return BackendResult(success=False, error="No scroll backends configured")
33
+
34
+ return run_chain(chain, backends, context, skip)
@@ -0,0 +1,48 @@
1
+ """TTS backend orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from textrun.runner import BackendResult, run_chain
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def synthesize(
15
+ config: dict[str, Any],
16
+ context: dict[str, Any],
17
+ skip: set[str] | None = None,
18
+ ) -> BackendResult:
19
+ """Convert text to speech using the configured backend chain.
20
+
21
+ Args:
22
+ config: The 'tts' section from the profile config.
23
+ context: Runtime context with text, output_path, etc.
24
+ skip: Optional set of backend names to skip (failed earlier in this run).
25
+
26
+ Returns:
27
+ BackendResult with output containing the audio file path.
28
+ """
29
+ chain = config.get("chain", [])
30
+ backends = config.get("backends", {})
31
+
32
+ if not chain:
33
+ return BackendResult(success=False, error="No TTS backends configured")
34
+
35
+ output_path = context.get("tts_output_path", "/tmp/textrun/tts_output.wav")
36
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
37
+
38
+ ctx = {
39
+ **context,
40
+ "output_path": output_path,
41
+ "stdin_text": context.get("text", ""),
42
+ }
43
+ result = run_chain(chain, backends, ctx, skip)
44
+
45
+ if result.success and not result.output:
46
+ result.output = output_path
47
+
48
+ return result