prezo 0.3.1__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.
- prezo/__init__.py +216 -0
- prezo/app.py +947 -0
- prezo/config.py +247 -0
- prezo/export.py +833 -0
- prezo/images/__init__.py +14 -0
- prezo/images/ascii.py +240 -0
- prezo/images/base.py +111 -0
- prezo/images/chafa.py +137 -0
- prezo/images/iterm.py +126 -0
- prezo/images/kitty.py +360 -0
- prezo/images/overlay.py +291 -0
- prezo/images/processor.py +139 -0
- prezo/images/sixel.py +180 -0
- prezo/parser.py +456 -0
- prezo/screens/__init__.py +21 -0
- prezo/screens/base.py +65 -0
- prezo/screens/blackout.py +60 -0
- prezo/screens/goto.py +99 -0
- prezo/screens/help.py +140 -0
- prezo/screens/overview.py +184 -0
- prezo/screens/search.py +252 -0
- prezo/screens/toc.py +254 -0
- prezo/terminal.py +147 -0
- prezo/themes.py +129 -0
- prezo/widgets/__init__.py +9 -0
- prezo/widgets/image_display.py +117 -0
- prezo/widgets/slide_button.py +72 -0
- prezo/widgets/status_bar.py +240 -0
- prezo-0.3.1.dist-info/METADATA +194 -0
- prezo-0.3.1.dist-info/RECORD +32 -0
- prezo-0.3.1.dist-info/WHEEL +4 -0
- prezo-0.3.1.dist-info/entry_points.txt +3 -0
prezo/images/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Image rendering for Prezo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import ImageRenderer, get_renderer
|
|
6
|
+
from .processor import process_slide_images, render_image, resolve_image_path
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ImageRenderer",
|
|
10
|
+
"get_renderer",
|
|
11
|
+
"process_slide_images",
|
|
12
|
+
"render_image",
|
|
13
|
+
"resolve_image_path",
|
|
14
|
+
]
|
prezo/images/ascii.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""ASCII art image renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# ASCII characters from dark to light
|
|
9
|
+
ASCII_CHARS = " .:-=+*#%@"
|
|
10
|
+
ASCII_CHARS_DETAILED = (
|
|
11
|
+
" .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsciiRenderer:
|
|
16
|
+
"""Render images as ASCII art."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, detailed: bool = False) -> None:
|
|
19
|
+
"""Initialize ASCII renderer.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
detailed: Use detailed character set for more gradients.
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
self.chars = ASCII_CHARS_DETAILED if detailed else ASCII_CHARS
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def name(self) -> str:
|
|
29
|
+
"""Get the renderer name."""
|
|
30
|
+
return "ascii"
|
|
31
|
+
|
|
32
|
+
def supports_inline(self) -> bool:
|
|
33
|
+
"""ASCII art supports inline display."""
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
def render(self, path: Path, width: int, height: int) -> str:
|
|
37
|
+
"""Render an image as ASCII art.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
path: Path to the image file.
|
|
41
|
+
width: Target width in characters.
|
|
42
|
+
height: Target height in lines.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
ASCII art string representation.
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
return self._render_image(path, width, height)
|
|
50
|
+
except Exception:
|
|
51
|
+
return self._render_placeholder(path, width, height)
|
|
52
|
+
|
|
53
|
+
def _render_image(self, path: Path, width: int, height: int) -> str:
|
|
54
|
+
"""Render image using PIL."""
|
|
55
|
+
from PIL import Image
|
|
56
|
+
|
|
57
|
+
img = Image.open(path)
|
|
58
|
+
|
|
59
|
+
# Convert to grayscale
|
|
60
|
+
img = img.convert("L")
|
|
61
|
+
|
|
62
|
+
# Calculate aspect ratio correction
|
|
63
|
+
# Terminal characters are typically ~2x taller than wide
|
|
64
|
+
aspect_ratio = img.width / img.height
|
|
65
|
+
new_width = min(width, int(height * 2 * aspect_ratio))
|
|
66
|
+
new_height = min(height, int(new_width / aspect_ratio / 2))
|
|
67
|
+
|
|
68
|
+
# Resize
|
|
69
|
+
img = img.resize((new_width, new_height))
|
|
70
|
+
|
|
71
|
+
# Convert to ASCII
|
|
72
|
+
pixels = list(img.getdata())
|
|
73
|
+
lines = []
|
|
74
|
+
|
|
75
|
+
for y in range(new_height):
|
|
76
|
+
line = ""
|
|
77
|
+
for x in range(new_width):
|
|
78
|
+
pixel = pixels[y * new_width + x]
|
|
79
|
+
# Map pixel value (0-255) to character
|
|
80
|
+
char_idx = int(pixel / 256 * len(self.chars))
|
|
81
|
+
char_idx = min(char_idx, len(self.chars) - 1)
|
|
82
|
+
line += self.chars[char_idx]
|
|
83
|
+
lines.append(line)
|
|
84
|
+
|
|
85
|
+
return "\n".join(lines)
|
|
86
|
+
|
|
87
|
+
def _render_placeholder(self, path: Path, width: int, height: int) -> str:
|
|
88
|
+
"""Render a placeholder when image can't be loaded."""
|
|
89
|
+
name = (
|
|
90
|
+
path.name if len(path.name) < width - 4 else path.name[: width - 7] + "..."
|
|
91
|
+
)
|
|
92
|
+
box_width = max(len(name) + 4, 20)
|
|
93
|
+
box_width = min(box_width, width)
|
|
94
|
+
|
|
95
|
+
lines = []
|
|
96
|
+
lines.append("┌" + "─" * (box_width - 2) + "┐")
|
|
97
|
+
lines.append("│" + " " * (box_width - 2) + "│")
|
|
98
|
+
lines.append("│" + f"[Image: {name}]".center(box_width - 2) + "│")
|
|
99
|
+
lines.append("│" + " " * (box_width - 2) + "│")
|
|
100
|
+
lines.append("└" + "─" * (box_width - 2) + "┘")
|
|
101
|
+
|
|
102
|
+
return "\n".join(lines)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ColorAsciiRenderer(AsciiRenderer):
|
|
106
|
+
"""Render images as colored ASCII art using ANSI colors."""
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def name(self) -> str:
|
|
110
|
+
"""Get the renderer name."""
|
|
111
|
+
return "ascii-color"
|
|
112
|
+
|
|
113
|
+
def _render_image(self, path: Path, width: int, height: int) -> str:
|
|
114
|
+
"""Render image as colored ASCII."""
|
|
115
|
+
from PIL import Image
|
|
116
|
+
|
|
117
|
+
img = Image.open(path)
|
|
118
|
+
|
|
119
|
+
# Keep color, convert to RGB
|
|
120
|
+
img = img.convert("RGB")
|
|
121
|
+
|
|
122
|
+
# Calculate aspect ratio correction
|
|
123
|
+
aspect_ratio = img.width / img.height
|
|
124
|
+
new_width = min(width, int(height * 2 * aspect_ratio))
|
|
125
|
+
new_height = min(height, int(new_width / aspect_ratio / 2))
|
|
126
|
+
|
|
127
|
+
# Resize
|
|
128
|
+
img = img.resize((new_width, new_height))
|
|
129
|
+
|
|
130
|
+
# Convert to colored ASCII
|
|
131
|
+
pixels = list(img.getdata())
|
|
132
|
+
lines = []
|
|
133
|
+
|
|
134
|
+
for y in range(new_height):
|
|
135
|
+
line = ""
|
|
136
|
+
for x in range(new_width):
|
|
137
|
+
r, g, b = pixels[y * new_width + x]
|
|
138
|
+
# Use half-block character with true color
|
|
139
|
+
line += f"\x1b[38;2;{r};{g};{b}m█\x1b[0m"
|
|
140
|
+
lines.append(line)
|
|
141
|
+
|
|
142
|
+
return "\n".join(lines)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class HalfBlockRenderer:
|
|
146
|
+
"""Render images using Unicode half-block characters for higher resolution."""
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def name(self) -> str:
|
|
150
|
+
"""Get the renderer name."""
|
|
151
|
+
return "halfblock"
|
|
152
|
+
|
|
153
|
+
def supports_inline(self) -> bool:
|
|
154
|
+
"""Half-block supports inline display."""
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
def render(self, path: Path, width: int, height: int) -> str:
|
|
158
|
+
"""Render image using half-block characters.
|
|
159
|
+
|
|
160
|
+
Each character cell displays 2 vertical pixels using the upper
|
|
161
|
+
half block character (▀) with foreground and background colors.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
path: Path to the image file.
|
|
165
|
+
width: Target width in characters.
|
|
166
|
+
height: Target height in lines.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Colored half-block string representation.
|
|
170
|
+
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
return self._render_image(path, width, height)
|
|
174
|
+
except Exception:
|
|
175
|
+
return AsciiRenderer()._render_placeholder(path, width, height)
|
|
176
|
+
|
|
177
|
+
def _render_image(self, path: Path, width: int, height: int) -> str:
|
|
178
|
+
"""Render image using half-blocks."""
|
|
179
|
+
from PIL import Image
|
|
180
|
+
|
|
181
|
+
img = Image.open(path)
|
|
182
|
+
img = img.convert("RGB")
|
|
183
|
+
|
|
184
|
+
# Calculate dimensions (height is doubled because of half-blocks)
|
|
185
|
+
aspect_ratio = img.width / img.height
|
|
186
|
+
new_width = min(width, int(height * 2 * aspect_ratio))
|
|
187
|
+
# Height in pixels (2 pixels per character row)
|
|
188
|
+
new_height = min(height * 2, int(new_width / aspect_ratio))
|
|
189
|
+
# Make height even
|
|
190
|
+
new_height = new_height - (new_height % 2)
|
|
191
|
+
|
|
192
|
+
img = img.resize((new_width, new_height))
|
|
193
|
+
pixels = list(img.getdata())
|
|
194
|
+
|
|
195
|
+
lines = []
|
|
196
|
+
for y in range(0, new_height, 2):
|
|
197
|
+
line = ""
|
|
198
|
+
for x in range(new_width):
|
|
199
|
+
# Upper pixel
|
|
200
|
+
r1, g1, b1 = pixels[y * new_width + x]
|
|
201
|
+
# Lower pixel (or black if at edge)
|
|
202
|
+
if y + 1 < new_height:
|
|
203
|
+
r2, g2, b2 = pixels[(y + 1) * new_width + x]
|
|
204
|
+
else:
|
|
205
|
+
r2, g2, b2 = 0, 0, 0
|
|
206
|
+
|
|
207
|
+
# Upper half block: foreground = top, background = bottom
|
|
208
|
+
line += f"\x1b[38;2;{r1};{g1};{b1};48;2;{r2};{g2};{b2}m▀\x1b[0m"
|
|
209
|
+
lines.append(line)
|
|
210
|
+
|
|
211
|
+
return "\n".join(lines)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@lru_cache(maxsize=32)
|
|
215
|
+
def render_cached(
|
|
216
|
+
renderer_name: str,
|
|
217
|
+
path: str,
|
|
218
|
+
width: int,
|
|
219
|
+
height: int,
|
|
220
|
+
) -> str:
|
|
221
|
+
"""Render an image with caching.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
renderer_name: Name of the renderer to use.
|
|
225
|
+
path: Path to the image file.
|
|
226
|
+
width: Target width.
|
|
227
|
+
height: Target height.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Rendered image string.
|
|
231
|
+
|
|
232
|
+
"""
|
|
233
|
+
renderers = {
|
|
234
|
+
"ascii": AsciiRenderer,
|
|
235
|
+
"ascii-color": ColorAsciiRenderer,
|
|
236
|
+
"halfblock": HalfBlockRenderer,
|
|
237
|
+
}
|
|
238
|
+
renderer_cls = renderers.get(renderer_name, AsciiRenderer)
|
|
239
|
+
renderer = renderer_cls()
|
|
240
|
+
return renderer.render(Path(path), width, height)
|
prezo/images/base.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Base image renderer protocol and factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol
|
|
6
|
+
|
|
7
|
+
from prezo.config import get_config
|
|
8
|
+
from prezo.terminal import ImageCapability, detect_image_capability
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ImageRenderer(Protocol):
|
|
15
|
+
"""Protocol for image renderers."""
|
|
16
|
+
|
|
17
|
+
def render(self, path: Path, width: int, height: int) -> str:
|
|
18
|
+
"""Render an image to a string representation.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
path: Path to the image file.
|
|
22
|
+
width: Target width in characters.
|
|
23
|
+
height: Target height in lines.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
String representation of the image for terminal display.
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def supports_inline(self) -> bool:
|
|
32
|
+
"""Check if renderer supports inline display with text.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
True if images can be displayed inline.
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def name(self) -> str:
|
|
42
|
+
"""Get the renderer name."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NullRenderer:
|
|
47
|
+
"""Null renderer that produces no output."""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def name(self) -> str:
|
|
51
|
+
"""Get the renderer name."""
|
|
52
|
+
return "none"
|
|
53
|
+
|
|
54
|
+
def render(self, path: Path, width: int, height: int) -> str:
|
|
55
|
+
"""Return empty string (no rendering)."""
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
def supports_inline(self) -> bool:
|
|
59
|
+
"""Null renderer doesn't support inline."""
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_renderer(mode: str | None = None) -> ImageRenderer:
|
|
64
|
+
"""Get an image renderer based on mode or auto-detection.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
mode: Explicit mode ('auto', 'kitty', 'sixel', 'iterm', 'ascii', 'none').
|
|
68
|
+
If None, uses config setting.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
An image renderer instance.
|
|
72
|
+
|
|
73
|
+
"""
|
|
74
|
+
if mode is None:
|
|
75
|
+
mode = get_config().images.mode
|
|
76
|
+
|
|
77
|
+
if mode == "none":
|
|
78
|
+
return NullRenderer()
|
|
79
|
+
|
|
80
|
+
if mode == "auto":
|
|
81
|
+
capability = detect_image_capability()
|
|
82
|
+
else:
|
|
83
|
+
# Map mode string to capability
|
|
84
|
+
mode_map = {
|
|
85
|
+
"kitty": ImageCapability.KITTY,
|
|
86
|
+
"sixel": ImageCapability.SIXEL,
|
|
87
|
+
"iterm": ImageCapability.ITERM,
|
|
88
|
+
"ascii": ImageCapability.ASCII,
|
|
89
|
+
}
|
|
90
|
+
capability = mode_map.get(mode, ImageCapability.ASCII)
|
|
91
|
+
|
|
92
|
+
# Return appropriate renderer
|
|
93
|
+
if capability == ImageCapability.KITTY:
|
|
94
|
+
from .kitty import KittyRenderer
|
|
95
|
+
|
|
96
|
+
return KittyRenderer()
|
|
97
|
+
|
|
98
|
+
if capability == ImageCapability.ITERM:
|
|
99
|
+
from .iterm import ItermRenderer
|
|
100
|
+
|
|
101
|
+
return ItermRenderer()
|
|
102
|
+
|
|
103
|
+
if capability == ImageCapability.SIXEL:
|
|
104
|
+
from .sixel import SixelRenderer
|
|
105
|
+
|
|
106
|
+
return SixelRenderer()
|
|
107
|
+
|
|
108
|
+
# Default to ASCII
|
|
109
|
+
from .ascii import AsciiRenderer
|
|
110
|
+
|
|
111
|
+
return AsciiRenderer()
|
prezo/images/chafa.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Chafa-based image renderer for high-quality terminal graphics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def chafa_available() -> bool:
|
|
14
|
+
"""Check if chafa is installed."""
|
|
15
|
+
return shutil.which("chafa") is not None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_with_chafa(
|
|
19
|
+
path: Path,
|
|
20
|
+
width: int,
|
|
21
|
+
height: int,
|
|
22
|
+
*,
|
|
23
|
+
symbols: str = "block+border+space",
|
|
24
|
+
colors: str = "full",
|
|
25
|
+
) -> str | None:
|
|
26
|
+
"""Render an image using chafa.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
path: Path to the image file.
|
|
30
|
+
width: Target width in characters.
|
|
31
|
+
height: Target height in lines.
|
|
32
|
+
symbols: Symbol set to use (block, border, space, ascii, etc.).
|
|
33
|
+
colors: Color mode (full, 256, 16, 8, 2, none).
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Rendered image as ANSI string, or None if chafa not available.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
if not chafa_available():
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
if not path.exists():
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
chafa_path = shutil.which("chafa")
|
|
46
|
+
if not chafa_path:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
[
|
|
52
|
+
chafa_path,
|
|
53
|
+
"--size",
|
|
54
|
+
f"{width}x{height}",
|
|
55
|
+
"--format",
|
|
56
|
+
"symbols", # Force symbols, not native protocols
|
|
57
|
+
"--symbols",
|
|
58
|
+
symbols,
|
|
59
|
+
"--colors",
|
|
60
|
+
colors,
|
|
61
|
+
"--color-space",
|
|
62
|
+
"rgb",
|
|
63
|
+
"--dither",
|
|
64
|
+
"ordered",
|
|
65
|
+
"--work",
|
|
66
|
+
"9", # Maximum quality
|
|
67
|
+
str(path),
|
|
68
|
+
],
|
|
69
|
+
check=False,
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
timeout=10,
|
|
73
|
+
)
|
|
74
|
+
if result.returncode == 0:
|
|
75
|
+
return result.stdout
|
|
76
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ChafaRenderer:
|
|
83
|
+
"""Render images using chafa for high-quality terminal graphics."""
|
|
84
|
+
|
|
85
|
+
def __init__(self) -> None:
|
|
86
|
+
"""Initialize the chafa renderer."""
|
|
87
|
+
self._available = chafa_available()
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def name(self) -> str:
|
|
91
|
+
"""Get the renderer name."""
|
|
92
|
+
return "chafa"
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def available(self) -> bool:
|
|
96
|
+
"""Check if chafa is available."""
|
|
97
|
+
return self._available
|
|
98
|
+
|
|
99
|
+
def supports_inline(self) -> bool:
|
|
100
|
+
"""Chafa supports inline display."""
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
def render(self, path: Path, width: int, height: int) -> str:
|
|
104
|
+
"""Render an image using chafa.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
path: Path to the image file.
|
|
108
|
+
width: Target width in characters.
|
|
109
|
+
height: Target height in lines.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Rendered image as ANSI string.
|
|
113
|
+
|
|
114
|
+
"""
|
|
115
|
+
result = render_with_chafa(path, width, height)
|
|
116
|
+
if result:
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
# Fall back to placeholder if chafa fails
|
|
120
|
+
return self._render_placeholder(path, width, height)
|
|
121
|
+
|
|
122
|
+
def _render_placeholder(self, path: Path, width: int, height: int) -> str:
|
|
123
|
+
"""Render a placeholder when image can't be loaded."""
|
|
124
|
+
name = (
|
|
125
|
+
path.name if len(path.name) < width - 4 else path.name[: width - 7] + "..."
|
|
126
|
+
)
|
|
127
|
+
box_width = max(len(name) + 4, 20)
|
|
128
|
+
box_width = min(box_width, width)
|
|
129
|
+
|
|
130
|
+
lines = []
|
|
131
|
+
lines.append("┌" + "─" * (box_width - 2) + "┐")
|
|
132
|
+
lines.append("│" + " " * (box_width - 2) + "│")
|
|
133
|
+
lines.append("│" + f"[Image: {name}]".center(box_width - 2) + "│")
|
|
134
|
+
lines.append("│" + " " * (box_width - 2) + "│")
|
|
135
|
+
lines.append("└" + "─" * (box_width - 2) + "┘")
|
|
136
|
+
|
|
137
|
+
return "\n".join(lines)
|
prezo/images/iterm.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""iTerm2 image renderer using inline images protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ItermRenderer:
|
|
14
|
+
"""Render images using iTerm2's inline images protocol."""
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def name(self) -> str:
|
|
18
|
+
"""Get the renderer name."""
|
|
19
|
+
return "iterm"
|
|
20
|
+
|
|
21
|
+
def supports_inline(self) -> bool:
|
|
22
|
+
"""iTerm2 supports inline images."""
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
def render(self, path: Path, width: int, height: int) -> str:
|
|
26
|
+
"""Render an image using iTerm2 inline images protocol.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
path: Path to the image file.
|
|
30
|
+
width: Target width in characters.
|
|
31
|
+
height: Target height in lines.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Escape sequence string to display the image.
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
return self._render_image(path, width, height)
|
|
39
|
+
except Exception:
|
|
40
|
+
from .ascii import AsciiRenderer
|
|
41
|
+
|
|
42
|
+
return AsciiRenderer()._render_placeholder(path, width, height)
|
|
43
|
+
|
|
44
|
+
def _render_image(self, path: Path, width: int, height: int) -> str:
|
|
45
|
+
"""Render image using iTerm2 protocol."""
|
|
46
|
+
# Read and encode image
|
|
47
|
+
image_data = path.read_bytes()
|
|
48
|
+
encoded = base64.b64encode(image_data).decode("ascii")
|
|
49
|
+
|
|
50
|
+
# Get file name for the name parameter
|
|
51
|
+
name = base64.b64encode(path.name.encode()).decode("ascii")
|
|
52
|
+
|
|
53
|
+
# Build iTerm2 inline image escape sequence
|
|
54
|
+
# Format: \x1b]1337;File=name=<b64name>;size=<bytes>;width=<w>;height=<h>;inline=1:<b64data>\x07
|
|
55
|
+
#
|
|
56
|
+
# name: base64 encoded filename
|
|
57
|
+
# size: file size in bytes
|
|
58
|
+
# width: width (can be pixels, cells, percent, or auto)
|
|
59
|
+
# height: height (same options)
|
|
60
|
+
# inline: 1 = display inline, 0 = download
|
|
61
|
+
# preserveAspectRatio: 1 = preserve, 0 = stretch
|
|
62
|
+
|
|
63
|
+
params = [
|
|
64
|
+
f"name={name}",
|
|
65
|
+
f"size={len(image_data)}",
|
|
66
|
+
f"width={width}",
|
|
67
|
+
f"height={height}",
|
|
68
|
+
"inline=1",
|
|
69
|
+
"preserveAspectRatio=1",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
param_str = ";".join(params)
|
|
73
|
+
return f"\x1b]1337;File={param_str}:{encoded}\x07"
|
|
74
|
+
|
|
75
|
+
def render_with_options(
|
|
76
|
+
self,
|
|
77
|
+
path: Path,
|
|
78
|
+
*,
|
|
79
|
+
width: str = "auto",
|
|
80
|
+
height: str = "auto",
|
|
81
|
+
preserve_aspect: bool = True,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Render image with more control over sizing.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
path: Path to the image file.
|
|
87
|
+
width: Width specification (e.g., "80", "50%", "auto", "80px").
|
|
88
|
+
height: Height specification.
|
|
89
|
+
preserve_aspect: Whether to preserve aspect ratio.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Escape sequence string.
|
|
93
|
+
|
|
94
|
+
"""
|
|
95
|
+
image_data = path.read_bytes()
|
|
96
|
+
encoded = base64.b64encode(image_data).decode("ascii")
|
|
97
|
+
name = base64.b64encode(path.name.encode()).decode("ascii")
|
|
98
|
+
|
|
99
|
+
params = [
|
|
100
|
+
f"name={name}",
|
|
101
|
+
f"size={len(image_data)}",
|
|
102
|
+
f"width={width}",
|
|
103
|
+
f"height={height}",
|
|
104
|
+
"inline=1",
|
|
105
|
+
f"preserveAspectRatio={1 if preserve_aspect else 0}",
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
param_str = ";".join(params)
|
|
109
|
+
return f"\x1b]1337;File={param_str}:{encoded}\x07"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def write_image_to_terminal(path: Path, width: int = 80, height: int = 24) -> None:
|
|
113
|
+
"""Write an image directly to the terminal.
|
|
114
|
+
|
|
115
|
+
Utility function for direct terminal output.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
path: Path to the image file.
|
|
119
|
+
width: Target width in cells.
|
|
120
|
+
height: Target height in cells.
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
renderer = ItermRenderer()
|
|
124
|
+
output = renderer.render(path, width, height)
|
|
125
|
+
sys.stdout.write(output)
|
|
126
|
+
sys.stdout.flush()
|