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,139 @@
1
+ """Image processing for slide content."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from prezo.parser import ImageRef, Slide
10
+
11
+ from .ascii import AsciiRenderer
12
+
13
+ # Default dimensions for rendered images
14
+ DEFAULT_IMAGE_WIDTH = 60
15
+ DEFAULT_IMAGE_HEIGHT = 15
16
+
17
+
18
+ def get_inline_renderer() -> AsciiRenderer:
19
+ """Get a renderer suitable for inline display in markdown.
20
+
21
+ Terminal-specific protocols (Kitty, iTerm2, Sixel, colored half-blocks)
22
+ use ANSI escape sequences that get escaped by Textual's Markdown widget.
23
+ For inline display, we use plain ASCII art which renders correctly.
24
+ """
25
+ from .ascii import AsciiRenderer
26
+
27
+ return AsciiRenderer(detailed=True)
28
+
29
+
30
+ def resolve_image_path(image_path: str, presentation_path: Path | None) -> Path | None:
31
+ """Resolve an image path relative to the presentation file.
32
+
33
+ Args:
34
+ image_path: Path as written in markdown (can be relative or absolute).
35
+ presentation_path: Path to the presentation file.
36
+
37
+ Returns:
38
+ Resolved absolute path, or None if unresolvable.
39
+
40
+ """
41
+ # Handle URLs (not supported yet)
42
+ if image_path.startswith(("http://", "https://", "data:")):
43
+ return None
44
+
45
+ path = Path(image_path)
46
+
47
+ # If absolute, use as-is
48
+ if path.is_absolute():
49
+ return path if path.exists() else None
50
+
51
+ # If relative, resolve against presentation directory
52
+ if presentation_path:
53
+ resolved = presentation_path.parent / path
54
+ if resolved.exists():
55
+ return resolved
56
+
57
+ # Try current directory as fallback
58
+ if path.exists():
59
+ return path.absolute()
60
+
61
+ return None
62
+
63
+
64
+ def render_image(
65
+ image_ref: ImageRef,
66
+ presentation_path: Path | None,
67
+ *,
68
+ width: int = DEFAULT_IMAGE_WIDTH,
69
+ height: int = DEFAULT_IMAGE_HEIGHT,
70
+ ) -> str:
71
+ """Render a single image reference as text for inline display.
72
+
73
+ Args:
74
+ image_ref: The image reference to render.
75
+ presentation_path: Path to the presentation file for relative paths.
76
+ width: Target width in characters.
77
+ height: Target height in lines.
78
+
79
+ Returns:
80
+ Rendered image as text, or a placeholder if rendering fails.
81
+
82
+ Note:
83
+ Always uses HalfBlockRenderer for inline display since terminal-specific
84
+ protocols (Kitty, iTerm2, Sixel) use escape sequences that get escaped
85
+ by Textual's Markdown widget.
86
+
87
+ """
88
+ resolved_path = resolve_image_path(image_ref.path, presentation_path)
89
+
90
+ if resolved_path is None:
91
+ # Return a placeholder for unresolvable images
92
+ alt = image_ref.alt or "image"
93
+ return f"[Image: {alt}]"
94
+
95
+ renderer = get_inline_renderer()
96
+ return renderer.render(resolved_path, width, height)
97
+
98
+
99
+ def process_slide_images(
100
+ slide: Slide,
101
+ presentation_path: Path | None,
102
+ *,
103
+ width: int = DEFAULT_IMAGE_WIDTH,
104
+ height: int = DEFAULT_IMAGE_HEIGHT,
105
+ ) -> str:
106
+ """Process a slide's content, rendering images inline.
107
+
108
+ Replaces markdown image syntax with rendered half-block versions.
109
+ Uses HalfBlockRenderer which displays 2 pixels per character using
110
+ Unicode half-block characters with true color support.
111
+
112
+ Args:
113
+ slide: The slide to process.
114
+ presentation_path: Path to the presentation file.
115
+ width: Target width for images in characters.
116
+ height: Target height for images in lines.
117
+
118
+ Returns:
119
+ Slide content with images replaced by rendered versions.
120
+
121
+ """
122
+ if not slide.images:
123
+ return slide.content
124
+
125
+ # Process images in reverse order to preserve positions
126
+ content = slide.content
127
+ for image_ref in reversed(slide.images):
128
+ rendered = render_image(
129
+ image_ref,
130
+ presentation_path,
131
+ width=width,
132
+ height=height,
133
+ )
134
+
135
+ # Wrap in code block for proper display in markdown
136
+ wrapped = f"\n```\n{rendered}\n```\n"
137
+ content = content[: image_ref.start] + wrapped + content[image_ref.end :]
138
+
139
+ return content
prezo/images/sixel.py ADDED
@@ -0,0 +1,180 @@
1
+ """Sixel graphics image renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from pathlib import Path
9
+
10
+
11
+ class SixelRenderer:
12
+ """Render images using Sixel graphics."""
13
+
14
+ @property
15
+ def name(self) -> str:
16
+ """Get the renderer name."""
17
+ return "sixel"
18
+
19
+ def supports_inline(self) -> bool:
20
+ """Sixel supports inline images."""
21
+ return True
22
+
23
+ def render(self, path: Path, width: int, height: int) -> str:
24
+ """Render an image using Sixel graphics.
25
+
26
+ Args:
27
+ path: Path to the image file.
28
+ width: Target width in characters.
29
+ height: Target height in lines.
30
+
31
+ Returns:
32
+ Sixel escape sequence string to display the image.
33
+
34
+ """
35
+ try:
36
+ return self._render_with_libsixel(path, width, height)
37
+ except ImportError:
38
+ # libsixel not available, try PIL-based fallback
39
+ try:
40
+ return self._render_with_pil(path, width, height)
41
+ except Exception:
42
+ from .ascii import AsciiRenderer
43
+
44
+ return AsciiRenderer()._render_placeholder(path, width, height)
45
+ except Exception:
46
+ from .ascii import AsciiRenderer
47
+
48
+ return AsciiRenderer()._render_placeholder(path, width, height)
49
+
50
+ def _render_with_libsixel(self, path: Path, width: int, height: int) -> str:
51
+ """Render using libsixel-python."""
52
+ import libsixel
53
+ from PIL import Image
54
+
55
+ # Load and resize image
56
+ img = Image.open(path)
57
+ img = img.convert("RGB")
58
+
59
+ # Calculate pixel dimensions
60
+ # Assume ~10 pixels per character width, ~20 per height
61
+ pixel_width = width * 10
62
+ pixel_height = height * 20
63
+
64
+ # Maintain aspect ratio
65
+ aspect = img.width / img.height
66
+ if pixel_width / pixel_height > aspect:
67
+ pixel_width = int(pixel_height * aspect)
68
+ else:
69
+ pixel_height = int(pixel_width / aspect)
70
+
71
+ img = img.resize((pixel_width, pixel_height))
72
+
73
+ # Convert to sixel
74
+ output = libsixel.encoder()
75
+ output.setopt(libsixel.SIXEL_OPTFLAG_WIDTH, str(pixel_width))
76
+ output.setopt(libsixel.SIXEL_OPTFLAG_HEIGHT, str(pixel_height))
77
+
78
+ # Encode to sixel string
79
+ # This is a simplified approach - actual libsixel usage may vary
80
+ data = img.tobytes()
81
+ return output.encode(data, pixel_width, pixel_height)
82
+
83
+ def _render_with_pil(self, path: Path, width: int, height: int) -> str:
84
+ """Render using pure PIL (simplified sixel encoder).
85
+
86
+ This is a basic sixel encoder for terminals that support sixel
87
+ but when libsixel is not available.
88
+
89
+ """
90
+ from PIL import Image
91
+
92
+ img = Image.open(path)
93
+ img = img.convert("P", palette=Image.ADAPTIVE, colors=256)
94
+
95
+ # Calculate pixel dimensions
96
+ pixel_width = width * 8
97
+ pixel_height = height * 16
98
+
99
+ # Maintain aspect ratio
100
+ aspect = img.width / img.height
101
+ if pixel_width / pixel_height > aspect:
102
+ pixel_width = int(pixel_height * aspect)
103
+ else:
104
+ pixel_height = int(pixel_width / aspect)
105
+
106
+ # Make height divisible by 6 (sixel row height)
107
+ pixel_height = (pixel_height // 6) * 6
108
+
109
+ img = img.resize((pixel_width, pixel_height))
110
+
111
+ # Get palette
112
+ palette = img.getpalette()
113
+ pixels = list(img.getdata())
114
+
115
+ # Build sixel output
116
+ output = []
117
+
118
+ # Start sixel sequence
119
+ output.append("\x1bPq")
120
+
121
+ # Define colors from palette
122
+ if palette:
123
+ for i in range(256):
124
+ r = palette[i * 3]
125
+ g = palette[i * 3 + 1]
126
+ b = palette[i * 3 + 2]
127
+ # Convert to percentages
128
+ r_pct = int(r / 255 * 100)
129
+ g_pct = int(g / 255 * 100)
130
+ b_pct = int(b / 255 * 100)
131
+ output.append(f"#{i};2;{r_pct};{g_pct};{b_pct}")
132
+
133
+ # Encode pixels in sixel format
134
+ # Each sixel row is 6 pixels high
135
+ for row in range(0, pixel_height, 6):
136
+ # For each color used in this row
137
+ colors_in_row: dict[int, list[int]] = {}
138
+
139
+ for y in range(6):
140
+ if row + y >= pixel_height:
141
+ break
142
+ for x in range(pixel_width):
143
+ pixel_idx = (row + y) * pixel_width + x
144
+ if pixel_idx < len(pixels):
145
+ color = pixels[pixel_idx]
146
+ if color not in colors_in_row:
147
+ colors_in_row[color] = [0] * pixel_width
148
+ colors_in_row[color][x] |= 1 << y
149
+
150
+ # Output each color's sixels for this row
151
+ for color, sixels in colors_in_row.items():
152
+ output.append(f"#{color}")
153
+ output.extend(chr(s + 63) for s in sixels)
154
+ output.append("$") # Carriage return
155
+
156
+ output.append("-") # New line
157
+
158
+ # End sixel sequence
159
+ output.append("\x1b\\")
160
+
161
+ return "".join(output)
162
+
163
+
164
+ def is_sixel_available() -> bool:
165
+ """Check if sixel rendering is available."""
166
+ try:
167
+ import libsixel # noqa: F401
168
+
169
+ return True
170
+ except ImportError:
171
+ pass
172
+
173
+ try:
174
+ from PIL import Image # noqa: F401
175
+
176
+ return True
177
+ except ImportError:
178
+ pass
179
+
180
+ return False