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/export.py
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
"""Export functionality for prezo presentations.
|
|
2
|
+
|
|
3
|
+
Exports presentations to PDF and HTML formats, using Rich's console
|
|
4
|
+
rendering for PDF and custom HTML templates for web viewing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.markdown import Markdown
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.style import Style
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from .parser import parse_presentation
|
|
20
|
+
from .themes import get_theme
|
|
21
|
+
|
|
22
|
+
# Export result types
|
|
23
|
+
EXPORT_SUCCESS = 0
|
|
24
|
+
EXPORT_FAILED = 2
|
|
25
|
+
|
|
26
|
+
# SVG template without window chrome (for printing)
|
|
27
|
+
# Uses Rich's template format: {var} for substitution, {{ }} for literal braces
|
|
28
|
+
SVG_FORMAT_NO_CHROME = """\
|
|
29
|
+
<svg class="rich-terminal" viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">
|
|
30
|
+
<!-- Generated with Rich https://www.textualize.io -->
|
|
31
|
+
<style>
|
|
32
|
+
|
|
33
|
+
@font-face {{
|
|
34
|
+
font-family: "Fira Code";
|
|
35
|
+
src: local("FiraCode-Regular"),
|
|
36
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
|
37
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
|
38
|
+
font-style: normal;
|
|
39
|
+
font-weight: 400;
|
|
40
|
+
}}
|
|
41
|
+
@font-face {{
|
|
42
|
+
font-family: "Fira Code";
|
|
43
|
+
src: local("FiraCode-Bold"),
|
|
44
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
|
45
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
|
46
|
+
font-style: bold;
|
|
47
|
+
font-weight: 700;
|
|
48
|
+
}}
|
|
49
|
+
|
|
50
|
+
.{unique_id}-matrix {{
|
|
51
|
+
font-family: Fira Code, monospace;
|
|
52
|
+
font-size: {char_height}px;
|
|
53
|
+
line-height: {line_height}px;
|
|
54
|
+
font-variant-east-asian: full-width;
|
|
55
|
+
}}
|
|
56
|
+
|
|
57
|
+
{styles}
|
|
58
|
+
</style>
|
|
59
|
+
|
|
60
|
+
<defs>
|
|
61
|
+
<clipPath id="{unique_id}-clip-terminal">
|
|
62
|
+
<rect x="0" y="0" width="{width}" height="{height}" />
|
|
63
|
+
</clipPath>
|
|
64
|
+
{lines}
|
|
65
|
+
</defs>
|
|
66
|
+
|
|
67
|
+
<g transform="translate(0, 0)" clip-path="url(#{unique_id}-clip-terminal)">
|
|
68
|
+
{backgrounds}
|
|
69
|
+
<g class="{unique_id}-matrix">
|
|
70
|
+
{matrix}
|
|
71
|
+
</g>
|
|
72
|
+
</g>
|
|
73
|
+
</svg>
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def render_slide_to_svg(
|
|
78
|
+
content: str,
|
|
79
|
+
slide_num: int,
|
|
80
|
+
total_slides: int,
|
|
81
|
+
*,
|
|
82
|
+
theme_name: str = "dark",
|
|
83
|
+
width: int = 80,
|
|
84
|
+
height: int = 24,
|
|
85
|
+
chrome: bool = True,
|
|
86
|
+
) -> str:
|
|
87
|
+
"""Render a single slide to SVG using Rich console.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
content: The markdown content of the slide
|
|
91
|
+
slide_num: Current slide number (0-indexed)
|
|
92
|
+
total_slides: Total number of slides
|
|
93
|
+
theme_name: Theme to use for rendering
|
|
94
|
+
width: Console width in characters
|
|
95
|
+
height: Console height in lines
|
|
96
|
+
chrome: If True, include window decorations; if False, plain SVG for printing
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
SVG string of the rendered slide
|
|
100
|
+
|
|
101
|
+
"""
|
|
102
|
+
theme = get_theme(theme_name)
|
|
103
|
+
|
|
104
|
+
# Create a console that records output (file=StringIO suppresses terminal output)
|
|
105
|
+
console = Console(
|
|
106
|
+
width=width,
|
|
107
|
+
record=True,
|
|
108
|
+
force_terminal=True,
|
|
109
|
+
color_system="truecolor",
|
|
110
|
+
file=io.StringIO(), # Suppress terminal output
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Base style for the entire slide (background color)
|
|
114
|
+
base_style = Style(color=theme.text, bgcolor=theme.background)
|
|
115
|
+
|
|
116
|
+
# Render the markdown content
|
|
117
|
+
md = Markdown(content)
|
|
118
|
+
|
|
119
|
+
# Create a panel with the slide content (height - 2 for status bar and padding)
|
|
120
|
+
panel_height = height - 2
|
|
121
|
+
panel = Panel(
|
|
122
|
+
md,
|
|
123
|
+
title=f"[{theme.text_muted}]Slide {slide_num + 1}/{total_slides}[/]",
|
|
124
|
+
title_align="right",
|
|
125
|
+
border_style=Style(color=theme.primary),
|
|
126
|
+
style=Style(color=theme.text, bgcolor=theme.surface),
|
|
127
|
+
padding=(1, 2),
|
|
128
|
+
expand=True,
|
|
129
|
+
height=panel_height,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Print to the recording console with background
|
|
133
|
+
console.print(panel, style=base_style)
|
|
134
|
+
|
|
135
|
+
# Add status bar at the bottom
|
|
136
|
+
progress = (slide_num + 1) / total_slides
|
|
137
|
+
bar_width = 20
|
|
138
|
+
filled = int(progress * bar_width)
|
|
139
|
+
bar = "█" * filled + "░" * (bar_width - filled)
|
|
140
|
+
status_text = f" {bar} {slide_num + 1}/{total_slides} "
|
|
141
|
+
# Pad status bar to full width
|
|
142
|
+
status_text = status_text.ljust(width)
|
|
143
|
+
status = Text(status_text, style=Style(bgcolor=theme.primary, color=theme.text))
|
|
144
|
+
console.print(status, style=base_style)
|
|
145
|
+
|
|
146
|
+
# Export to SVG
|
|
147
|
+
if chrome:
|
|
148
|
+
svg = console.export_svg(title=f"Slide {slide_num + 1}")
|
|
149
|
+
else:
|
|
150
|
+
svg = console.export_svg(code_format=SVG_FORMAT_NO_CHROME)
|
|
151
|
+
|
|
152
|
+
# Add background color to SVG (Rich doesn't set it by default)
|
|
153
|
+
# Insert a rect element right after the opening svg tag
|
|
154
|
+
bg_rect = f'<rect width="100%" height="100%" fill="{theme.background}"/>'
|
|
155
|
+
return svg.replace(
|
|
156
|
+
'xmlns="http://www.w3.org/2000/svg">',
|
|
157
|
+
f'xmlns="http://www.w3.org/2000/svg">\n {bg_rect}',
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def combine_svgs_to_pdf(svg_files: list[Path], output: Path) -> tuple[int, str]:
|
|
162
|
+
"""Combine multiple SVG files into a single PDF.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
svg_files: List of paths to SVG files
|
|
166
|
+
output: Output PDF path
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Tuple of (exit_code, message)
|
|
170
|
+
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
import cairosvg # noqa: PLC0415
|
|
174
|
+
from pypdf import PdfReader, PdfWriter # noqa: PLC0415
|
|
175
|
+
except ImportError:
|
|
176
|
+
return EXPORT_FAILED, (
|
|
177
|
+
"Required packages not installed. Install with:\n"
|
|
178
|
+
" pip install cairosvg pypdf"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
pdf_pages = []
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
# Convert each SVG to a PDF page
|
|
185
|
+
for svg_file in svg_files:
|
|
186
|
+
pdf_bytes = cairosvg.svg2pdf(url=str(svg_file))
|
|
187
|
+
assert pdf_bytes is not None
|
|
188
|
+
pdf_pages.append(io.BytesIO(pdf_bytes))
|
|
189
|
+
|
|
190
|
+
# Combine all pages into one PDF
|
|
191
|
+
writer = PdfWriter()
|
|
192
|
+
for page_io in pdf_pages:
|
|
193
|
+
reader = PdfReader(page_io)
|
|
194
|
+
for page in reader.pages:
|
|
195
|
+
writer.add_page(page)
|
|
196
|
+
|
|
197
|
+
# Write the combined PDF
|
|
198
|
+
with open(output, "wb") as f:
|
|
199
|
+
writer.write(f)
|
|
200
|
+
|
|
201
|
+
return EXPORT_SUCCESS, f"Exported {len(svg_files)} slides to {output}"
|
|
202
|
+
|
|
203
|
+
except Exception as e:
|
|
204
|
+
return EXPORT_FAILED, f"PDF generation failed: {e}"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def export_to_pdf(
|
|
208
|
+
source: Path,
|
|
209
|
+
output: Path,
|
|
210
|
+
*,
|
|
211
|
+
theme: str = "dark",
|
|
212
|
+
width: int = 80,
|
|
213
|
+
height: int = 24,
|
|
214
|
+
chrome: bool = True,
|
|
215
|
+
) -> tuple[int, str]:
|
|
216
|
+
"""Export presentation to PDF matching TUI appearance.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
source: Path to the markdown presentation
|
|
220
|
+
output: Path for the output PDF
|
|
221
|
+
theme: Theme to use for rendering
|
|
222
|
+
width: Console width in characters
|
|
223
|
+
height: Console height in lines
|
|
224
|
+
chrome: If True, include window decorations; if False, plain output for printing
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Tuple of (exit_code, message)
|
|
228
|
+
|
|
229
|
+
"""
|
|
230
|
+
if not source.exists():
|
|
231
|
+
return EXPORT_FAILED, f"Source file not found: {source}"
|
|
232
|
+
|
|
233
|
+
# Parse the presentation
|
|
234
|
+
try:
|
|
235
|
+
presentation = parse_presentation(source)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
return EXPORT_FAILED, f"Failed to parse presentation: {e}"
|
|
238
|
+
|
|
239
|
+
if presentation.total_slides == 0:
|
|
240
|
+
return EXPORT_FAILED, "Presentation has no slides"
|
|
241
|
+
|
|
242
|
+
# Create temporary directory for SVG files
|
|
243
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
244
|
+
tmpdir = Path(tmpdir)
|
|
245
|
+
svg_files = []
|
|
246
|
+
|
|
247
|
+
# Render each slide to SVG
|
|
248
|
+
for i, slide in enumerate(presentation.slides):
|
|
249
|
+
svg_content = render_slide_to_svg(
|
|
250
|
+
slide.content,
|
|
251
|
+
i,
|
|
252
|
+
presentation.total_slides,
|
|
253
|
+
theme_name=theme,
|
|
254
|
+
width=width,
|
|
255
|
+
height=height,
|
|
256
|
+
chrome=chrome,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
svg_file = tmpdir / f"slide_{i:04d}.svg"
|
|
260
|
+
svg_file.write_text(svg_content)
|
|
261
|
+
svg_files.append(svg_file)
|
|
262
|
+
|
|
263
|
+
# Combine into PDF
|
|
264
|
+
return combine_svgs_to_pdf(svg_files, output)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# HTML export templates
|
|
268
|
+
HTML_TEMPLATE = """\
|
|
269
|
+
<!DOCTYPE html>
|
|
270
|
+
<html lang="en">
|
|
271
|
+
<head>
|
|
272
|
+
<meta charset="UTF-8">
|
|
273
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
274
|
+
<title>{title}</title>
|
|
275
|
+
<style>
|
|
276
|
+
* {{
|
|
277
|
+
margin: 0;
|
|
278
|
+
padding: 0;
|
|
279
|
+
box-sizing: border-box;
|
|
280
|
+
}}
|
|
281
|
+
body {{
|
|
282
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
283
|
+
background: {background};
|
|
284
|
+
color: {text};
|
|
285
|
+
min-height: 100vh;
|
|
286
|
+
}}
|
|
287
|
+
.slides {{
|
|
288
|
+
max-width: 1200px;
|
|
289
|
+
margin: 0 auto;
|
|
290
|
+
padding: 2rem;
|
|
291
|
+
}}
|
|
292
|
+
.slide {{
|
|
293
|
+
background: {surface};
|
|
294
|
+
border: 1px solid {border};
|
|
295
|
+
border-radius: 8px;
|
|
296
|
+
padding: 3rem 4rem;
|
|
297
|
+
margin-bottom: 3rem;
|
|
298
|
+
min-height: 70vh;
|
|
299
|
+
display: flex;
|
|
300
|
+
flex-direction: column;
|
|
301
|
+
page-break-after: always;
|
|
302
|
+
}}
|
|
303
|
+
.slide-number {{
|
|
304
|
+
color: {text_muted};
|
|
305
|
+
font-size: 0.9rem;
|
|
306
|
+
margin-bottom: 1rem;
|
|
307
|
+
padding-bottom: 0.5rem;
|
|
308
|
+
border-bottom: 1px solid {border};
|
|
309
|
+
}}
|
|
310
|
+
.slide-content {{
|
|
311
|
+
flex: 1;
|
|
312
|
+
}}
|
|
313
|
+
h1 {{
|
|
314
|
+
font-size: 2.5rem;
|
|
315
|
+
margin-bottom: 1.5rem;
|
|
316
|
+
color: {primary};
|
|
317
|
+
}}
|
|
318
|
+
h2 {{
|
|
319
|
+
font-size: 2rem;
|
|
320
|
+
margin-bottom: 1.2rem;
|
|
321
|
+
color: {primary};
|
|
322
|
+
}}
|
|
323
|
+
h3 {{
|
|
324
|
+
font-size: 1.5rem;
|
|
325
|
+
margin-bottom: 1rem;
|
|
326
|
+
color: {text};
|
|
327
|
+
}}
|
|
328
|
+
p {{
|
|
329
|
+
font-size: 1.2rem;
|
|
330
|
+
line-height: 1.6;
|
|
331
|
+
margin-bottom: 1rem;
|
|
332
|
+
}}
|
|
333
|
+
ul, ol {{
|
|
334
|
+
margin: 1rem 0;
|
|
335
|
+
padding-left: 2rem;
|
|
336
|
+
}}
|
|
337
|
+
li {{
|
|
338
|
+
font-size: 1.2rem;
|
|
339
|
+
line-height: 1.6;
|
|
340
|
+
margin-bottom: 0.5rem;
|
|
341
|
+
}}
|
|
342
|
+
pre {{
|
|
343
|
+
background: {background};
|
|
344
|
+
border-radius: 4px;
|
|
345
|
+
padding: 1rem;
|
|
346
|
+
overflow-x: auto;
|
|
347
|
+
font-family: 'Fira Code', 'Consolas', monospace;
|
|
348
|
+
font-size: 1rem;
|
|
349
|
+
margin: 1rem 0;
|
|
350
|
+
}}
|
|
351
|
+
code {{
|
|
352
|
+
font-family: 'Fira Code', 'Consolas', monospace;
|
|
353
|
+
background: {background};
|
|
354
|
+
padding: 0.2rem 0.4rem;
|
|
355
|
+
border-radius: 3px;
|
|
356
|
+
font-size: 0.95em;
|
|
357
|
+
}}
|
|
358
|
+
pre code {{
|
|
359
|
+
padding: 0;
|
|
360
|
+
background: none;
|
|
361
|
+
}}
|
|
362
|
+
table {{
|
|
363
|
+
width: 100%;
|
|
364
|
+
border-collapse: collapse;
|
|
365
|
+
margin: 1rem 0;
|
|
366
|
+
}}
|
|
367
|
+
th, td {{
|
|
368
|
+
border: 1px solid {border};
|
|
369
|
+
padding: 0.75rem;
|
|
370
|
+
text-align: left;
|
|
371
|
+
}}
|
|
372
|
+
th {{
|
|
373
|
+
background: {background};
|
|
374
|
+
}}
|
|
375
|
+
blockquote {{
|
|
376
|
+
border-left: 4px solid {primary};
|
|
377
|
+
padding-left: 1rem;
|
|
378
|
+
margin: 1rem 0;
|
|
379
|
+
font-style: italic;
|
|
380
|
+
color: {text_muted};
|
|
381
|
+
}}
|
|
382
|
+
a {{
|
|
383
|
+
color: {primary};
|
|
384
|
+
}}
|
|
385
|
+
img {{
|
|
386
|
+
max-width: 100%;
|
|
387
|
+
height: auto;
|
|
388
|
+
}}
|
|
389
|
+
.notes {{
|
|
390
|
+
margin-top: 2rem;
|
|
391
|
+
padding: 1rem;
|
|
392
|
+
background: {background};
|
|
393
|
+
border-radius: 4px;
|
|
394
|
+
font-size: 0.9rem;
|
|
395
|
+
color: {text_muted};
|
|
396
|
+
}}
|
|
397
|
+
.notes-title {{
|
|
398
|
+
font-weight: bold;
|
|
399
|
+
margin-bottom: 0.5rem;
|
|
400
|
+
}}
|
|
401
|
+
@media print {{
|
|
402
|
+
.slide {{
|
|
403
|
+
break-inside: avoid;
|
|
404
|
+
page-break-inside: avoid;
|
|
405
|
+
}}
|
|
406
|
+
body {{
|
|
407
|
+
background: white;
|
|
408
|
+
color: black;
|
|
409
|
+
}}
|
|
410
|
+
}}
|
|
411
|
+
</style>
|
|
412
|
+
</head>
|
|
413
|
+
<body>
|
|
414
|
+
<div class="slides">
|
|
415
|
+
{slides}
|
|
416
|
+
</div>
|
|
417
|
+
</body>
|
|
418
|
+
</html>
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
SLIDE_TEMPLATE = """\
|
|
422
|
+
<div class="slide" id="slide-{num}">
|
|
423
|
+
<div class="slide-number">Slide {display_num} of {total}</div>
|
|
424
|
+
<div class="slide-content">
|
|
425
|
+
{content}
|
|
426
|
+
</div>
|
|
427
|
+
{notes}
|
|
428
|
+
</div>
|
|
429
|
+
"""
|
|
430
|
+
|
|
431
|
+
NOTES_TEMPLATE = """\
|
|
432
|
+
<div class="notes">
|
|
433
|
+
<div class="notes-title">Presenter Notes</div>
|
|
434
|
+
{notes_content}
|
|
435
|
+
</div>
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def render_slide_to_html(content: str) -> str:
|
|
440
|
+
"""Convert markdown content to basic HTML.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
content: Markdown content of the slide.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
HTML string for the slide content.
|
|
447
|
+
|
|
448
|
+
"""
|
|
449
|
+
try:
|
|
450
|
+
import markdown # noqa: PLC0415
|
|
451
|
+
|
|
452
|
+
html = markdown.markdown(
|
|
453
|
+
content,
|
|
454
|
+
extensions=["tables", "fenced_code", "codehilite"],
|
|
455
|
+
)
|
|
456
|
+
except ImportError:
|
|
457
|
+
# Fallback: basic markdown-to-html conversion
|
|
458
|
+
import html as html_mod # noqa: PLC0415
|
|
459
|
+
|
|
460
|
+
html = html_mod.escape(content)
|
|
461
|
+
# Basic transformations
|
|
462
|
+
html = html.replace("\n\n", "</p><p>")
|
|
463
|
+
html = f"<p>{html}</p>"
|
|
464
|
+
|
|
465
|
+
return html
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def export_to_html(
|
|
469
|
+
source: Path,
|
|
470
|
+
output: Path,
|
|
471
|
+
*,
|
|
472
|
+
theme: str = "dark",
|
|
473
|
+
include_notes: bool = False,
|
|
474
|
+
) -> tuple[int, str]:
|
|
475
|
+
"""Export presentation to HTML.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
source: Path to the markdown presentation.
|
|
479
|
+
output: Path for the output HTML file.
|
|
480
|
+
theme: Theme to use for styling.
|
|
481
|
+
include_notes: Whether to include presenter notes.
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
Tuple of (exit_code, message).
|
|
485
|
+
|
|
486
|
+
"""
|
|
487
|
+
if not source.exists():
|
|
488
|
+
return EXPORT_FAILED, f"Source file not found: {source}"
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
presentation = parse_presentation(source)
|
|
492
|
+
except Exception as e:
|
|
493
|
+
return EXPORT_FAILED, f"Failed to parse presentation: {e}"
|
|
494
|
+
|
|
495
|
+
if presentation.total_slides == 0:
|
|
496
|
+
return EXPORT_FAILED, "Presentation has no slides"
|
|
497
|
+
|
|
498
|
+
theme_obj = get_theme(theme)
|
|
499
|
+
|
|
500
|
+
# Render each slide
|
|
501
|
+
slides_html = []
|
|
502
|
+
for i, slide in enumerate(presentation.slides):
|
|
503
|
+
content_html = render_slide_to_html(slide.content)
|
|
504
|
+
|
|
505
|
+
# Handle notes
|
|
506
|
+
notes_html = ""
|
|
507
|
+
if include_notes and slide.notes:
|
|
508
|
+
notes_content = render_slide_to_html(slide.notes)
|
|
509
|
+
notes_html = NOTES_TEMPLATE.format(notes_content=notes_content)
|
|
510
|
+
|
|
511
|
+
slide_html = SLIDE_TEMPLATE.format(
|
|
512
|
+
num=i,
|
|
513
|
+
display_num=i + 1,
|
|
514
|
+
total=presentation.total_slides,
|
|
515
|
+
content=content_html,
|
|
516
|
+
notes=notes_html,
|
|
517
|
+
)
|
|
518
|
+
slides_html.append(slide_html)
|
|
519
|
+
|
|
520
|
+
# Build final HTML
|
|
521
|
+
title = presentation.title or source.stem
|
|
522
|
+
html = HTML_TEMPLATE.format(
|
|
523
|
+
title=title,
|
|
524
|
+
background=theme_obj.background,
|
|
525
|
+
surface=theme_obj.surface,
|
|
526
|
+
text=theme_obj.text,
|
|
527
|
+
text_muted=theme_obj.text_muted,
|
|
528
|
+
primary=theme_obj.primary,
|
|
529
|
+
border=theme_obj.text_muted,
|
|
530
|
+
slides="\n".join(slides_html),
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
output.write_text(html)
|
|
535
|
+
return (
|
|
536
|
+
EXPORT_SUCCESS,
|
|
537
|
+
f"Exported {presentation.total_slides} slides to {output}",
|
|
538
|
+
)
|
|
539
|
+
except Exception as e:
|
|
540
|
+
return EXPORT_FAILED, f"Failed to write HTML: {e}"
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def run_export(
|
|
544
|
+
source: str,
|
|
545
|
+
output: str | None = None,
|
|
546
|
+
*,
|
|
547
|
+
theme: str = "dark",
|
|
548
|
+
width: int = 80,
|
|
549
|
+
height: int = 24,
|
|
550
|
+
chrome: bool = True,
|
|
551
|
+
) -> int:
|
|
552
|
+
"""Run PDF export from command line.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
source: Path to the markdown presentation (string)
|
|
556
|
+
output: Optional path for the output PDF (string)
|
|
557
|
+
theme: Theme to use for rendering
|
|
558
|
+
width: Console width in characters
|
|
559
|
+
height: Console height in lines
|
|
560
|
+
chrome: If True, include window decorations; if False, plain output for printing
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
Exit code (0 for success)
|
|
564
|
+
|
|
565
|
+
"""
|
|
566
|
+
source_path = Path(source)
|
|
567
|
+
output_path = Path(output) if output else source_path.with_suffix(".pdf")
|
|
568
|
+
|
|
569
|
+
code, _message = export_to_pdf(
|
|
570
|
+
source_path,
|
|
571
|
+
output_path,
|
|
572
|
+
theme=theme,
|
|
573
|
+
width=width,
|
|
574
|
+
height=height,
|
|
575
|
+
chrome=chrome,
|
|
576
|
+
)
|
|
577
|
+
if code == EXPORT_SUCCESS:
|
|
578
|
+
pass
|
|
579
|
+
else:
|
|
580
|
+
pass
|
|
581
|
+
return code
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def run_html_export(
|
|
585
|
+
source: str,
|
|
586
|
+
output: str | None = None,
|
|
587
|
+
*,
|
|
588
|
+
theme: str = "light",
|
|
589
|
+
include_notes: bool = False,
|
|
590
|
+
) -> int:
|
|
591
|
+
"""Run HTML export from command line.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
source: Path to the markdown presentation (string).
|
|
595
|
+
output: Optional path for the output HTML (string).
|
|
596
|
+
theme: Theme to use for styling.
|
|
597
|
+
include_notes: Whether to include presenter notes.
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
Exit code (0 for success).
|
|
601
|
+
|
|
602
|
+
"""
|
|
603
|
+
source_path = Path(source)
|
|
604
|
+
output_path = Path(output) if output else source_path.with_suffix(".html")
|
|
605
|
+
|
|
606
|
+
code, _message = export_to_html(
|
|
607
|
+
source_path,
|
|
608
|
+
output_path,
|
|
609
|
+
theme=theme,
|
|
610
|
+
include_notes=include_notes,
|
|
611
|
+
)
|
|
612
|
+
return code
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def export_slide_to_image(
|
|
616
|
+
content: str,
|
|
617
|
+
slide_num: int,
|
|
618
|
+
total_slides: int,
|
|
619
|
+
output_path: Path,
|
|
620
|
+
*,
|
|
621
|
+
output_format: str = "png",
|
|
622
|
+
theme_name: str = "dark",
|
|
623
|
+
width: int = 80,
|
|
624
|
+
height: int = 24,
|
|
625
|
+
chrome: bool = True,
|
|
626
|
+
scale: float = 1.0,
|
|
627
|
+
) -> tuple[int, str]:
|
|
628
|
+
"""Export a single slide to PNG or SVG.
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
content: The markdown content of the slide.
|
|
632
|
+
slide_num: Current slide number (0-indexed).
|
|
633
|
+
total_slides: Total number of slides.
|
|
634
|
+
output_path: Path to save the image.
|
|
635
|
+
output_format: Output format ('png' or 'svg').
|
|
636
|
+
theme_name: Theme to use for rendering.
|
|
637
|
+
width: Console width in characters.
|
|
638
|
+
height: Console height in lines.
|
|
639
|
+
chrome: If True, include window decorations.
|
|
640
|
+
scale: Scale factor for PNG output (e.g., 2.0 for 2x resolution).
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
Tuple of (exit_code, message).
|
|
644
|
+
|
|
645
|
+
"""
|
|
646
|
+
# Generate SVG
|
|
647
|
+
svg_content = render_slide_to_svg(
|
|
648
|
+
content,
|
|
649
|
+
slide_num,
|
|
650
|
+
total_slides,
|
|
651
|
+
theme_name=theme_name,
|
|
652
|
+
width=width,
|
|
653
|
+
height=height,
|
|
654
|
+
chrome=chrome,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
if output_format == "svg":
|
|
658
|
+
try:
|
|
659
|
+
output_path.write_text(svg_content)
|
|
660
|
+
return EXPORT_SUCCESS, f"Exported slide {slide_num + 1} to {output_path}"
|
|
661
|
+
except Exception as e:
|
|
662
|
+
return EXPORT_FAILED, f"Failed to write SVG: {e}"
|
|
663
|
+
|
|
664
|
+
# Convert SVG to PNG
|
|
665
|
+
try:
|
|
666
|
+
import cairosvg # noqa: PLC0415
|
|
667
|
+
except ImportError:
|
|
668
|
+
return EXPORT_FAILED, (
|
|
669
|
+
"PNG export requires cairosvg.\nInstall with: pip install prezo[export]"
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
try:
|
|
673
|
+
png_data = cairosvg.svg2png(
|
|
674
|
+
bytestring=svg_content.encode("utf-8"),
|
|
675
|
+
scale=scale,
|
|
676
|
+
)
|
|
677
|
+
output_path.write_bytes(png_data)
|
|
678
|
+
return EXPORT_SUCCESS, f"Exported slide {slide_num + 1} to {output_path}"
|
|
679
|
+
except Exception as e:
|
|
680
|
+
return EXPORT_FAILED, f"Failed to convert to PNG: {e}"
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def export_to_images(
|
|
684
|
+
source: Path,
|
|
685
|
+
output: Path | None = None,
|
|
686
|
+
*,
|
|
687
|
+
output_format: str = "png",
|
|
688
|
+
theme: str = "dark",
|
|
689
|
+
width: int = 80,
|
|
690
|
+
height: int = 24,
|
|
691
|
+
chrome: bool = True,
|
|
692
|
+
slide_num: int | None = None,
|
|
693
|
+
scale: float = 2.0,
|
|
694
|
+
) -> tuple[int, str]:
|
|
695
|
+
"""Export presentation slides to images.
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
source: Path to the markdown presentation.
|
|
699
|
+
output: Output path (file for single slide, directory for all).
|
|
700
|
+
output_format: Output format ('png' or 'svg').
|
|
701
|
+
theme: Theme to use for rendering.
|
|
702
|
+
width: Console width in characters.
|
|
703
|
+
height: Console height in lines.
|
|
704
|
+
chrome: If True, include window decorations.
|
|
705
|
+
slide_num: If set, export only this slide (1-indexed).
|
|
706
|
+
scale: Scale factor for PNG output (default 2.0 for higher resolution).
|
|
707
|
+
|
|
708
|
+
Returns:
|
|
709
|
+
Tuple of (exit_code, message).
|
|
710
|
+
|
|
711
|
+
"""
|
|
712
|
+
# Parse presentation
|
|
713
|
+
try:
|
|
714
|
+
presentation = parse_presentation(source)
|
|
715
|
+
except Exception as e:
|
|
716
|
+
return EXPORT_FAILED, f"Failed to read {source}: {e}"
|
|
717
|
+
|
|
718
|
+
if presentation.total_slides == 0:
|
|
719
|
+
return EXPORT_FAILED, "No slides found in presentation"
|
|
720
|
+
|
|
721
|
+
# Single slide export
|
|
722
|
+
if slide_num is not None:
|
|
723
|
+
if slide_num < 1 or slide_num > presentation.total_slides:
|
|
724
|
+
return EXPORT_FAILED, (
|
|
725
|
+
f"Invalid slide number: {slide_num}. "
|
|
726
|
+
f"Presentation has {presentation.total_slides} slides."
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
slide_idx = slide_num - 1
|
|
730
|
+
slide = presentation.slides[slide_idx]
|
|
731
|
+
|
|
732
|
+
out_path = Path(output) if output else source.with_suffix(f".{output_format}")
|
|
733
|
+
|
|
734
|
+
return export_slide_to_image(
|
|
735
|
+
slide.content,
|
|
736
|
+
slide_idx,
|
|
737
|
+
presentation.total_slides,
|
|
738
|
+
out_path,
|
|
739
|
+
output_format=output_format,
|
|
740
|
+
theme_name=theme,
|
|
741
|
+
width=width,
|
|
742
|
+
height=height,
|
|
743
|
+
chrome=chrome,
|
|
744
|
+
scale=scale,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
# Export all slides
|
|
748
|
+
if output:
|
|
749
|
+
out_dir = Path(output)
|
|
750
|
+
if out_dir.suffix: # Has extension, treat as file prefix
|
|
751
|
+
prefix = out_dir.stem # Get stem before reassigning
|
|
752
|
+
out_dir = out_dir.parent
|
|
753
|
+
else:
|
|
754
|
+
prefix = source.stem
|
|
755
|
+
else:
|
|
756
|
+
out_dir = source.parent
|
|
757
|
+
prefix = source.stem
|
|
758
|
+
|
|
759
|
+
# Create output directory if needed
|
|
760
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
761
|
+
|
|
762
|
+
exported = 0
|
|
763
|
+
for i, slide in enumerate(presentation.slides):
|
|
764
|
+
out_path = out_dir / f"{prefix}_{i + 1:03d}.{output_format}"
|
|
765
|
+
code, msg = export_slide_to_image(
|
|
766
|
+
slide.content,
|
|
767
|
+
i,
|
|
768
|
+
presentation.total_slides,
|
|
769
|
+
out_path,
|
|
770
|
+
output_format=output_format,
|
|
771
|
+
theme_name=theme,
|
|
772
|
+
width=width,
|
|
773
|
+
height=height,
|
|
774
|
+
chrome=chrome,
|
|
775
|
+
scale=scale,
|
|
776
|
+
)
|
|
777
|
+
if code != EXPORT_SUCCESS:
|
|
778
|
+
return code, msg
|
|
779
|
+
exported += 1
|
|
780
|
+
|
|
781
|
+
return EXPORT_SUCCESS, f"Exported {exported} slides to {out_dir}/"
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def run_image_export(
|
|
785
|
+
source: str,
|
|
786
|
+
output: str | None = None,
|
|
787
|
+
*,
|
|
788
|
+
output_format: str = "png",
|
|
789
|
+
theme: str = "dark",
|
|
790
|
+
width: int = 80,
|
|
791
|
+
height: int = 24,
|
|
792
|
+
chrome: bool = True,
|
|
793
|
+
slide_num: int | None = None,
|
|
794
|
+
scale: float = 2.0,
|
|
795
|
+
) -> int:
|
|
796
|
+
"""Run PNG/SVG export from command line.
|
|
797
|
+
|
|
798
|
+
Args:
|
|
799
|
+
source: Path to the markdown presentation.
|
|
800
|
+
output: Optional output path (file or directory).
|
|
801
|
+
output_format: Output format ('png' or 'svg').
|
|
802
|
+
theme: Theme to use for rendering.
|
|
803
|
+
width: Console width in characters.
|
|
804
|
+
height: Console height in lines.
|
|
805
|
+
chrome: If True, include window decorations.
|
|
806
|
+
slide_num: If set, export only this slide (1-indexed).
|
|
807
|
+
scale: Scale factor for PNG output (default 2.0 for higher resolution).
|
|
808
|
+
|
|
809
|
+
Returns:
|
|
810
|
+
Exit code (0 for success).
|
|
811
|
+
|
|
812
|
+
"""
|
|
813
|
+
source_path = Path(source)
|
|
814
|
+
output_path = Path(output) if output else None
|
|
815
|
+
|
|
816
|
+
code, message = export_to_images(
|
|
817
|
+
source_path,
|
|
818
|
+
output_path,
|
|
819
|
+
output_format=output_format,
|
|
820
|
+
theme=theme,
|
|
821
|
+
width=width,
|
|
822
|
+
height=height,
|
|
823
|
+
chrome=chrome,
|
|
824
|
+
slide_num=slide_num,
|
|
825
|
+
scale=scale,
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
if code == EXPORT_SUCCESS:
|
|
829
|
+
print(message)
|
|
830
|
+
else:
|
|
831
|
+
print(f"error: {message}", file=__import__("sys").stderr)
|
|
832
|
+
|
|
833
|
+
return code
|