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
|
@@ -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
|