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/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
+ )