prezo 2026.1.1__py3-none-any.whl → 2026.1.3__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 +8 -0
- prezo/app.py +25 -31
- prezo/config.py +1 -1
- prezo/export/__init__.py +36 -0
- prezo/export/common.py +77 -0
- prezo/export/html.py +340 -0
- prezo/export/images.py +261 -0
- prezo/export/pdf.py +497 -0
- prezo/export/svg.py +170 -0
- prezo/layout.py +680 -0
- prezo/parser.py +11 -4
- prezo/widgets/__init__.py +9 -1
- prezo/widgets/slide_content.py +81 -0
- {prezo-2026.1.1.dist-info → prezo-2026.1.3.dist-info}/METADATA +25 -4
- {prezo-2026.1.1.dist-info → prezo-2026.1.3.dist-info}/RECORD +17 -10
- prezo/export.py +0 -835
- {prezo-2026.1.1.dist-info → prezo-2026.1.3.dist-info}/WHEEL +0 -0
- {prezo-2026.1.1.dist-info → prezo-2026.1.3.dist-info}/entry_points.txt +0 -0
prezo/export/pdf.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""PDF export functionality with multiple backend support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from prezo.parser import parse_presentation
|
|
14
|
+
|
|
15
|
+
from .common import (
|
|
16
|
+
EXIT_FAILURE,
|
|
17
|
+
EXIT_SUCCESS,
|
|
18
|
+
ExportError,
|
|
19
|
+
check_font_availability,
|
|
20
|
+
print_font_warnings,
|
|
21
|
+
)
|
|
22
|
+
from .svg import render_slide_to_svg
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _find_chrome() -> str | None:
|
|
26
|
+
"""Find Chrome/Chromium executable.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Path to Chrome executable, or None if not found.
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
# Try common Chrome executable names
|
|
33
|
+
for name in ["chromium", "google-chrome", "chrome", "google-chrome-stable"]:
|
|
34
|
+
path = shutil.which(name)
|
|
35
|
+
if path:
|
|
36
|
+
return path
|
|
37
|
+
|
|
38
|
+
# macOS application paths
|
|
39
|
+
mac_paths = [
|
|
40
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
41
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
42
|
+
]
|
|
43
|
+
for path in mac_paths:
|
|
44
|
+
if Path(path).exists():
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _convert_svg_to_pdf_chrome(svg_file: Path, pdf_file: Path) -> bool:
|
|
51
|
+
"""Convert a single SVG to PDF using Chrome headless.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
svg_file: Path to SVG file.
|
|
55
|
+
pdf_file: Path for output PDF.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
True if successful, False otherwise.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
chrome = _find_chrome()
|
|
62
|
+
if not chrome:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
# Read SVG and get dimensions from viewBox
|
|
66
|
+
svg_content = svg_file.read_text()
|
|
67
|
+
match = re.search(r'viewBox="0 0 ([\d.]+) ([\d.]+)"', svg_content)
|
|
68
|
+
if match:
|
|
69
|
+
width = int(float(match.group(1)))
|
|
70
|
+
height = int(float(match.group(2)))
|
|
71
|
+
else:
|
|
72
|
+
width, height = 994, 612
|
|
73
|
+
|
|
74
|
+
# Create HTML wrapper with proper page size
|
|
75
|
+
# overflow:hidden prevents extra blank pages from slight size mismatches
|
|
76
|
+
html_content = f"""<!DOCTYPE html>
|
|
77
|
+
<html>
|
|
78
|
+
<head>
|
|
79
|
+
<style>
|
|
80
|
+
@page {{ margin: 0; size: {width}px {height}px; }}
|
|
81
|
+
html, body {{ margin: 0; padding: 0; overflow: hidden; height: {height}px; width: {width}px; }}
|
|
82
|
+
svg {{ display: block; }}
|
|
83
|
+
</style>
|
|
84
|
+
</head>
|
|
85
|
+
<body>
|
|
86
|
+
{svg_content}
|
|
87
|
+
</body>
|
|
88
|
+
</html>"""
|
|
89
|
+
|
|
90
|
+
# Write HTML to temp file
|
|
91
|
+
with tempfile.NamedTemporaryFile(
|
|
92
|
+
mode="w", suffix=".html", delete=False
|
|
93
|
+
) as html_file:
|
|
94
|
+
html_file.write(html_content)
|
|
95
|
+
html_path = Path(html_file.name)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
result = subprocess.run(
|
|
99
|
+
[
|
|
100
|
+
chrome,
|
|
101
|
+
"--headless",
|
|
102
|
+
"--disable-gpu",
|
|
103
|
+
"--password-store=basic",
|
|
104
|
+
"--use-mock-keychain",
|
|
105
|
+
f"--print-to-pdf={pdf_file}",
|
|
106
|
+
"--no-pdf-header-footer",
|
|
107
|
+
str(html_path),
|
|
108
|
+
],
|
|
109
|
+
capture_output=True,
|
|
110
|
+
timeout=60,
|
|
111
|
+
check=False,
|
|
112
|
+
)
|
|
113
|
+
return result.returncode == 0 and pdf_file.exists()
|
|
114
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
115
|
+
return False
|
|
116
|
+
finally:
|
|
117
|
+
html_path.unlink(missing_ok=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _convert_svg_to_pdf_inkscape(svg_file: Path, pdf_file: Path) -> bool:
|
|
121
|
+
"""Convert a single SVG to PDF using Inkscape.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
svg_file: Path to SVG file.
|
|
125
|
+
pdf_file: Path for output PDF.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
True if successful, False otherwise.
|
|
129
|
+
|
|
130
|
+
"""
|
|
131
|
+
inkscape = shutil.which("inkscape")
|
|
132
|
+
if not inkscape:
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
result = subprocess.run(
|
|
137
|
+
[
|
|
138
|
+
inkscape,
|
|
139
|
+
str(svg_file),
|
|
140
|
+
f"--export-filename={pdf_file}",
|
|
141
|
+
"--export-type=pdf",
|
|
142
|
+
],
|
|
143
|
+
capture_output=True,
|
|
144
|
+
timeout=60,
|
|
145
|
+
check=False,
|
|
146
|
+
)
|
|
147
|
+
return result.returncode == 0 and pdf_file.exists()
|
|
148
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _convert_svg_to_pdf_cairosvg(svg_file: Path) -> bytes | None:
|
|
153
|
+
"""Convert a single SVG to PDF using CairoSVG.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
svg_file: Path to SVG file.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
PDF bytes if successful, None otherwise.
|
|
160
|
+
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
import cairosvg # noqa: PLC0415
|
|
164
|
+
except ImportError:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
# CairoSVG doesn't properly support textLength, so we strip it
|
|
168
|
+
svg_content = svg_file.read_text()
|
|
169
|
+
svg_content = re.sub(r'\s*textLength="[^"]*"', "", svg_content)
|
|
170
|
+
svg_content = re.sub(r'\s*lengthAdjust="[^"]*"', "", svg_content)
|
|
171
|
+
|
|
172
|
+
return cairosvg.svg2pdf(bytestring=svg_content.encode("utf-8"))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _select_pdf_backend(backend: str) -> tuple[str, str | None]:
|
|
176
|
+
"""Select the PDF backend to use.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
backend: Requested backend ("auto", "chrome", "inkscape", "cairosvg")
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Tuple of (selected_backend, error_message). Error is None if OK.
|
|
183
|
+
|
|
184
|
+
"""
|
|
185
|
+
checks = {
|
|
186
|
+
"chrome": (
|
|
187
|
+
_find_chrome,
|
|
188
|
+
"Chrome/Chromium not found. Install it or use a different backend.",
|
|
189
|
+
),
|
|
190
|
+
"inkscape": (
|
|
191
|
+
lambda: shutil.which("inkscape"),
|
|
192
|
+
"Inkscape not found. Install it or use --pdf-backend=cairosvg",
|
|
193
|
+
),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if backend in checks:
|
|
197
|
+
check_fn, error_msg = checks[backend]
|
|
198
|
+
return (backend, None) if check_fn() else ("", error_msg)
|
|
199
|
+
|
|
200
|
+
if backend == "cairosvg":
|
|
201
|
+
return "cairosvg", None
|
|
202
|
+
|
|
203
|
+
# auto: prefer Chrome > Inkscape > CairoSVG
|
|
204
|
+
for name, (check_fn, _) in checks.items():
|
|
205
|
+
if check_fn():
|
|
206
|
+
return name, None
|
|
207
|
+
return "cairosvg", None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def combine_svgs_to_pdf(
|
|
211
|
+
svg_files: list[Path], output: Path, *, backend: str = "auto"
|
|
212
|
+
) -> Path:
|
|
213
|
+
"""Combine multiple SVG files into a single PDF.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
svg_files: List of paths to SVG files
|
|
217
|
+
output: Output PDF path
|
|
218
|
+
backend: PDF conversion backend ("auto", "chrome", "inkscape", "cairosvg")
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Path to the created PDF file.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ExportError: If PDF generation fails.
|
|
225
|
+
|
|
226
|
+
"""
|
|
227
|
+
selected, error = _select_pdf_backend(backend)
|
|
228
|
+
if error:
|
|
229
|
+
raise ExportError(error)
|
|
230
|
+
|
|
231
|
+
match selected:
|
|
232
|
+
case "chrome":
|
|
233
|
+
return _combine_svgs_to_pdf_chrome(svg_files, output)
|
|
234
|
+
case "inkscape":
|
|
235
|
+
return _combine_svgs_to_pdf_inkscape(svg_files, output)
|
|
236
|
+
case _:
|
|
237
|
+
return _combine_svgs_to_pdf_cairosvg(svg_files, output)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _combine_svgs_to_pdf_chrome(svg_files: list[Path], output: Path) -> Path:
|
|
241
|
+
"""Combine SVGs to PDF using Chrome headless."""
|
|
242
|
+
try:
|
|
243
|
+
from pypdf import PdfReader, PdfWriter # noqa: PLC0415
|
|
244
|
+
except ImportError as e:
|
|
245
|
+
msg = "Required package not installed. Install with:\n pip install pypdf"
|
|
246
|
+
raise ExportError(msg) from e
|
|
247
|
+
|
|
248
|
+
pdf_pages: list[Path] = []
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
for svg_file in svg_files:
|
|
252
|
+
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
|
253
|
+
pdf_path = Path(tmp.name)
|
|
254
|
+
|
|
255
|
+
if not _convert_svg_to_pdf_chrome(svg_file, pdf_path):
|
|
256
|
+
for p in pdf_pages:
|
|
257
|
+
p.unlink(missing_ok=True)
|
|
258
|
+
msg = "Chrome PDF conversion failed"
|
|
259
|
+
raise ExportError(msg)
|
|
260
|
+
|
|
261
|
+
pdf_pages.append(pdf_path)
|
|
262
|
+
|
|
263
|
+
# Combine all pages
|
|
264
|
+
writer = PdfWriter()
|
|
265
|
+
for pdf_path in pdf_pages:
|
|
266
|
+
reader = PdfReader(pdf_path)
|
|
267
|
+
for page in reader.pages:
|
|
268
|
+
writer.add_page(page)
|
|
269
|
+
|
|
270
|
+
with open(output, "wb") as f:
|
|
271
|
+
writer.write(f)
|
|
272
|
+
|
|
273
|
+
# Clean up temp files
|
|
274
|
+
for p in pdf_pages:
|
|
275
|
+
p.unlink(missing_ok=True)
|
|
276
|
+
|
|
277
|
+
return output
|
|
278
|
+
|
|
279
|
+
except ExportError:
|
|
280
|
+
raise
|
|
281
|
+
except Exception as e:
|
|
282
|
+
for p in pdf_pages:
|
|
283
|
+
p.unlink(missing_ok=True)
|
|
284
|
+
msg = f"PDF generation failed: {e}"
|
|
285
|
+
raise ExportError(msg) from e
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _combine_svgs_to_pdf_inkscape(svg_files: list[Path], output: Path) -> Path:
|
|
289
|
+
"""Combine SVGs to PDF using Inkscape."""
|
|
290
|
+
try:
|
|
291
|
+
from pypdf import PdfReader, PdfWriter # noqa: PLC0415
|
|
292
|
+
except ImportError as e:
|
|
293
|
+
msg = "Required package not installed. Install with:\n pip install pypdf"
|
|
294
|
+
raise ExportError(msg) from e
|
|
295
|
+
|
|
296
|
+
pdf_pages: list[Path] = []
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
for svg_file in svg_files:
|
|
300
|
+
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
|
301
|
+
pdf_path = Path(tmp.name)
|
|
302
|
+
|
|
303
|
+
if not _convert_svg_to_pdf_inkscape(svg_file, pdf_path):
|
|
304
|
+
# Clean up and fail
|
|
305
|
+
for p in pdf_pages:
|
|
306
|
+
p.unlink(missing_ok=True)
|
|
307
|
+
msg = "Inkscape conversion failed"
|
|
308
|
+
raise ExportError(msg)
|
|
309
|
+
|
|
310
|
+
pdf_pages.append(pdf_path)
|
|
311
|
+
|
|
312
|
+
# Combine all pages
|
|
313
|
+
writer = PdfWriter()
|
|
314
|
+
for pdf_path in pdf_pages:
|
|
315
|
+
reader = PdfReader(pdf_path)
|
|
316
|
+
for page in reader.pages:
|
|
317
|
+
writer.add_page(page)
|
|
318
|
+
|
|
319
|
+
with open(output, "wb") as f:
|
|
320
|
+
writer.write(f)
|
|
321
|
+
|
|
322
|
+
# Clean up temp files
|
|
323
|
+
for p in pdf_pages:
|
|
324
|
+
p.unlink(missing_ok=True)
|
|
325
|
+
|
|
326
|
+
return output
|
|
327
|
+
|
|
328
|
+
except ExportError:
|
|
329
|
+
raise
|
|
330
|
+
except Exception as e:
|
|
331
|
+
for p in pdf_pages:
|
|
332
|
+
p.unlink(missing_ok=True)
|
|
333
|
+
msg = f"PDF generation failed: {e}"
|
|
334
|
+
raise ExportError(msg) from e
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _combine_svgs_to_pdf_cairosvg(svg_files: list[Path], output: Path) -> Path:
|
|
338
|
+
"""Combine SVGs to PDF using CairoSVG."""
|
|
339
|
+
try:
|
|
340
|
+
from pypdf import PdfReader, PdfWriter # noqa: PLC0415
|
|
341
|
+
except ImportError as e:
|
|
342
|
+
msg = (
|
|
343
|
+
"Required packages not installed. Install with:\n"
|
|
344
|
+
" pip install cairosvg pypdf"
|
|
345
|
+
)
|
|
346
|
+
raise ExportError(msg) from e
|
|
347
|
+
|
|
348
|
+
# Warn about CairoSVG limitations
|
|
349
|
+
print(
|
|
350
|
+
"⚠️ Using CairoSVG backend. For better alignment, install Inkscape "
|
|
351
|
+
"or use --pdf-backend=inkscape",
|
|
352
|
+
file=sys.stderr,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
pdf_pages = []
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
for svg_file in svg_files:
|
|
359
|
+
pdf_bytes = _convert_svg_to_pdf_cairosvg(svg_file)
|
|
360
|
+
if pdf_bytes is None:
|
|
361
|
+
msg = "CairoSVG not installed. Install with:\n pip install cairosvg"
|
|
362
|
+
raise ExportError(msg)
|
|
363
|
+
pdf_pages.append(io.BytesIO(pdf_bytes))
|
|
364
|
+
|
|
365
|
+
writer = PdfWriter()
|
|
366
|
+
for page_io in pdf_pages:
|
|
367
|
+
reader = PdfReader(page_io)
|
|
368
|
+
for page in reader.pages:
|
|
369
|
+
writer.add_page(page)
|
|
370
|
+
|
|
371
|
+
with open(output, "wb") as f:
|
|
372
|
+
writer.write(f)
|
|
373
|
+
|
|
374
|
+
return output
|
|
375
|
+
|
|
376
|
+
except ExportError:
|
|
377
|
+
raise
|
|
378
|
+
except Exception as e:
|
|
379
|
+
msg = f"PDF generation failed: {e}"
|
|
380
|
+
raise ExportError(msg) from e
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def export_to_pdf(
|
|
384
|
+
source: Path,
|
|
385
|
+
output: Path,
|
|
386
|
+
*,
|
|
387
|
+
theme: str = "dark",
|
|
388
|
+
width: int = 80,
|
|
389
|
+
height: int = 24,
|
|
390
|
+
chrome: bool = True,
|
|
391
|
+
pdf_backend: str = "auto",
|
|
392
|
+
) -> Path:
|
|
393
|
+
"""Export presentation to PDF matching TUI appearance.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
source: Path to the markdown presentation
|
|
397
|
+
output: Path for the output PDF
|
|
398
|
+
theme: Theme to use for rendering
|
|
399
|
+
width: Console width in characters
|
|
400
|
+
height: Console height in lines
|
|
401
|
+
chrome: If True, include window decorations; if False, plain output for printing
|
|
402
|
+
pdf_backend: Backend for PDF conversion ("auto", "inkscape", "cairosvg")
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
Path to the created PDF file.
|
|
406
|
+
|
|
407
|
+
Raises:
|
|
408
|
+
ExportError: If export fails.
|
|
409
|
+
|
|
410
|
+
"""
|
|
411
|
+
if not source.exists():
|
|
412
|
+
msg = f"Source file not found: {source}"
|
|
413
|
+
raise ExportError(msg)
|
|
414
|
+
|
|
415
|
+
# Parse the presentation
|
|
416
|
+
try:
|
|
417
|
+
presentation = parse_presentation(source)
|
|
418
|
+
except Exception as e:
|
|
419
|
+
msg = f"Failed to parse presentation: {e}"
|
|
420
|
+
raise ExportError(msg) from e
|
|
421
|
+
|
|
422
|
+
if presentation.total_slides == 0:
|
|
423
|
+
msg = "Presentation has no slides"
|
|
424
|
+
raise ExportError(msg)
|
|
425
|
+
|
|
426
|
+
# Check font availability and warn if needed
|
|
427
|
+
font_warnings = check_font_availability()
|
|
428
|
+
print_font_warnings(font_warnings)
|
|
429
|
+
|
|
430
|
+
# Create temporary directory for SVG files
|
|
431
|
+
with tempfile.TemporaryDirectory() as tmpdir_str:
|
|
432
|
+
tmpdir = Path(tmpdir_str)
|
|
433
|
+
svg_files = []
|
|
434
|
+
|
|
435
|
+
# Render each slide to SVG
|
|
436
|
+
for i, slide in enumerate(presentation.slides):
|
|
437
|
+
svg_content = render_slide_to_svg(
|
|
438
|
+
slide.content,
|
|
439
|
+
i,
|
|
440
|
+
presentation.total_slides,
|
|
441
|
+
theme_name=theme,
|
|
442
|
+
width=width,
|
|
443
|
+
height=height,
|
|
444
|
+
chrome=chrome,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
svg_file = tmpdir / f"slide_{i:04d}.svg"
|
|
448
|
+
svg_file.write_text(svg_content)
|
|
449
|
+
svg_files.append(svg_file)
|
|
450
|
+
|
|
451
|
+
# Combine into PDF
|
|
452
|
+
return combine_svgs_to_pdf(svg_files, output, backend=pdf_backend)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def run_export(
|
|
456
|
+
source: str,
|
|
457
|
+
output: str | None = None,
|
|
458
|
+
*,
|
|
459
|
+
theme: str = "dark",
|
|
460
|
+
width: int = 80,
|
|
461
|
+
height: int = 24,
|
|
462
|
+
chrome: bool = True,
|
|
463
|
+
pdf_backend: str = "auto",
|
|
464
|
+
) -> int:
|
|
465
|
+
"""Run PDF export from command line.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
source: Path to the markdown presentation (string)
|
|
469
|
+
output: Optional path for the output PDF (string)
|
|
470
|
+
theme: Theme to use for rendering
|
|
471
|
+
width: Console width in characters
|
|
472
|
+
height: Console height in lines
|
|
473
|
+
chrome: If True, include window decorations; if False, plain output for printing
|
|
474
|
+
pdf_backend: Backend for PDF conversion ("auto", "inkscape", "cairosvg")
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
Exit code (0 for success)
|
|
478
|
+
|
|
479
|
+
"""
|
|
480
|
+
source_path = Path(source)
|
|
481
|
+
output_path = Path(output) if output else source_path.with_suffix(".pdf")
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
result_path = export_to_pdf(
|
|
485
|
+
source_path,
|
|
486
|
+
output_path,
|
|
487
|
+
theme=theme,
|
|
488
|
+
width=width,
|
|
489
|
+
height=height,
|
|
490
|
+
chrome=chrome,
|
|
491
|
+
pdf_backend=pdf_backend,
|
|
492
|
+
)
|
|
493
|
+
print(f"Exported to {result_path}")
|
|
494
|
+
return EXIT_SUCCESS
|
|
495
|
+
except ExportError as e:
|
|
496
|
+
print(f"error: {e}", file=sys.stderr)
|
|
497
|
+
return EXIT_FAILURE
|
prezo/export/svg.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""SVG rendering for slides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.markdown import Markdown
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.style import Style
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from prezo.layout import has_layout_blocks, parse_layout, render_layout
|
|
14
|
+
from prezo.themes import get_theme
|
|
15
|
+
|
|
16
|
+
# SVG template without window chrome (for printing)
|
|
17
|
+
# Uses Rich's template format: {var} for substitution, {{ }} for literal braces
|
|
18
|
+
SVG_FORMAT_NO_CHROME = """\
|
|
19
|
+
<svg class="rich-terminal" viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">
|
|
20
|
+
<!-- Generated with Rich https://www.textualize.io -->
|
|
21
|
+
<style>
|
|
22
|
+
|
|
23
|
+
@font-face {{
|
|
24
|
+
font-family: "Fira Code";
|
|
25
|
+
src: local("FiraCode-Regular"),
|
|
26
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
|
27
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
|
28
|
+
font-style: normal;
|
|
29
|
+
font-weight: 400;
|
|
30
|
+
}}
|
|
31
|
+
@font-face {{
|
|
32
|
+
font-family: "Fira Code";
|
|
33
|
+
src: local("FiraCode-Bold"),
|
|
34
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
|
35
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
|
36
|
+
font-style: bold;
|
|
37
|
+
font-weight: 700;
|
|
38
|
+
}}
|
|
39
|
+
|
|
40
|
+
.{{unique_id}}-matrix {{
|
|
41
|
+
font-family: Fira Code, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace;
|
|
42
|
+
font-size: {{char_height}}px;
|
|
43
|
+
line-height: {{line_height}}px;
|
|
44
|
+
font-variant-east-asian: full-width;
|
|
45
|
+
/* Disable ligatures and ensure consistent character widths */
|
|
46
|
+
font-feature-settings: "liga" 0, "calt" 0, "dlig" 0;
|
|
47
|
+
font-variant-ligatures: none;
|
|
48
|
+
letter-spacing: 0;
|
|
49
|
+
word-spacing: 0;
|
|
50
|
+
white-space: pre;
|
|
51
|
+
}}
|
|
52
|
+
|
|
53
|
+
.{{unique_id}}-matrix text {{
|
|
54
|
+
/* Force uniform character spacing for box-drawing chars */
|
|
55
|
+
text-rendering: geometricPrecision;
|
|
56
|
+
}}
|
|
57
|
+
|
|
58
|
+
{{styles}}
|
|
59
|
+
</style>
|
|
60
|
+
|
|
61
|
+
<defs>
|
|
62
|
+
<clipPath id="{{unique_id}}-clip-terminal">
|
|
63
|
+
<rect x="0" y="0" width="{{width}}" height="{{height}}" />
|
|
64
|
+
</clipPath>
|
|
65
|
+
{{lines}}
|
|
66
|
+
</defs>
|
|
67
|
+
|
|
68
|
+
<g transform="translate(0, 0)" clip-path="url(#{{unique_id}}-clip-terminal)">
|
|
69
|
+
{{backgrounds}}
|
|
70
|
+
<g class="{{unique_id}}-matrix">
|
|
71
|
+
{{matrix}}
|
|
72
|
+
</g>
|
|
73
|
+
</g>
|
|
74
|
+
</svg>
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def render_slide_to_svg(
|
|
79
|
+
content: str,
|
|
80
|
+
slide_num: int,
|
|
81
|
+
total_slides: int,
|
|
82
|
+
*,
|
|
83
|
+
theme_name: str = "dark",
|
|
84
|
+
width: int = 80,
|
|
85
|
+
height: int = 24,
|
|
86
|
+
chrome: bool = True,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Render a single slide to SVG using Rich console.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
content: The markdown content of the slide
|
|
92
|
+
slide_num: Current slide number (0-indexed)
|
|
93
|
+
total_slides: Total number of slides
|
|
94
|
+
theme_name: Theme to use for rendering
|
|
95
|
+
width: Console width in characters
|
|
96
|
+
height: Console height in lines
|
|
97
|
+
chrome: If True, include window decorations; if False, plain SVG for printing
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
SVG string of the rendered slide
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
theme = get_theme(theme_name)
|
|
104
|
+
|
|
105
|
+
# Create a console that records output (file=StringIO suppresses terminal output)
|
|
106
|
+
console = Console(
|
|
107
|
+
width=width,
|
|
108
|
+
record=True,
|
|
109
|
+
force_terminal=True,
|
|
110
|
+
color_system="truecolor",
|
|
111
|
+
file=io.StringIO(), # Suppress terminal output
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Base style for the entire slide (background color)
|
|
115
|
+
base_style = Style(color=theme.text, bgcolor=theme.background)
|
|
116
|
+
|
|
117
|
+
# Render the content (with layout support)
|
|
118
|
+
if has_layout_blocks(content):
|
|
119
|
+
blocks = parse_layout(content)
|
|
120
|
+
slide_content = render_layout(blocks)
|
|
121
|
+
else:
|
|
122
|
+
slide_content = Markdown(content)
|
|
123
|
+
|
|
124
|
+
# Create a panel with the slide content (height - 2 for status bar and padding)
|
|
125
|
+
panel_height = height - 2
|
|
126
|
+
panel = Panel(
|
|
127
|
+
slide_content,
|
|
128
|
+
title=f"[{theme.text_muted}]Slide {slide_num + 1}/{total_slides}[/]",
|
|
129
|
+
title_align="right",
|
|
130
|
+
border_style=Style(color=theme.primary),
|
|
131
|
+
style=Style(color=theme.text, bgcolor=theme.surface),
|
|
132
|
+
padding=(1, 2),
|
|
133
|
+
expand=True,
|
|
134
|
+
height=panel_height,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Print to the recording console with background
|
|
138
|
+
console.print(panel, style=base_style)
|
|
139
|
+
|
|
140
|
+
# Add status bar at the bottom
|
|
141
|
+
progress = (slide_num + 1) / total_slides
|
|
142
|
+
bar_width = 20
|
|
143
|
+
filled = int(progress * bar_width)
|
|
144
|
+
bar = "█" * filled + "░" * (bar_width - filled)
|
|
145
|
+
status_text = f" {bar} {slide_num + 1}/{total_slides} "
|
|
146
|
+
# Pad status bar to full width
|
|
147
|
+
status_text = status_text.ljust(width)
|
|
148
|
+
status = Text(status_text, style=Style(bgcolor=theme.primary, color=theme.text))
|
|
149
|
+
console.print(status, style=base_style)
|
|
150
|
+
|
|
151
|
+
# Export to SVG
|
|
152
|
+
if chrome:
|
|
153
|
+
svg = console.export_svg(title=f"Slide {slide_num + 1}")
|
|
154
|
+
else:
|
|
155
|
+
svg = console.export_svg(code_format=SVG_FORMAT_NO_CHROME)
|
|
156
|
+
|
|
157
|
+
# Add emoji font fallbacks to font-family declarations
|
|
158
|
+
# Rich only specifies "Fira Code, monospace" which lacks emoji glyphs
|
|
159
|
+
svg = svg.replace(
|
|
160
|
+
"font-family: Fira Code, monospace",
|
|
161
|
+
'font-family: Fira Code, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Add background color to SVG (Rich doesn't set it by default)
|
|
165
|
+
# Insert a rect element right after the opening svg tag
|
|
166
|
+
bg_rect = f'<rect width="100%" height="100%" fill="{theme.background}"/>'
|
|
167
|
+
return svg.replace(
|
|
168
|
+
'xmlns="http://www.w3.org/2000/svg">',
|
|
169
|
+
f'xmlns="http://www.w3.org/2000/svg">\n {bg_rect}',
|
|
170
|
+
)
|