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