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.
@@ -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()