prezo 2026.1.2__py3-none-any.whl → 2026.1.4__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/html.py ADDED
@@ -0,0 +1,340 @@
1
+ """HTML export functionality."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from prezo.parser import clean_marp_directives, extract_notes, parse_presentation
8
+ from prezo.themes import get_theme
9
+
10
+ from .common import EXIT_FAILURE, EXIT_SUCCESS, ExportError
11
+
12
+ # HTML export templates
13
+ HTML_TEMPLATE = """\
14
+ <!DOCTYPE html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="UTF-8">
18
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
19
+ <title>{title}</title>
20
+ <style>
21
+ * {{
22
+ margin: 0;
23
+ padding: 0;
24
+ box-sizing: border-box;
25
+ }}
26
+ body {{
27
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
28
+ background: {background};
29
+ color: {text};
30
+ min-height: 100vh;
31
+ }}
32
+ .slides {{
33
+ max-width: 1200px;
34
+ margin: 0 auto;
35
+ padding: 2rem;
36
+ }}
37
+ .slide {{
38
+ background: {surface};
39
+ border: 1px solid {border};
40
+ border-radius: 8px;
41
+ padding: 3rem 4rem;
42
+ margin-bottom: 3rem;
43
+ min-height: 70vh;
44
+ display: flex;
45
+ flex-direction: column;
46
+ page-break-after: always;
47
+ }}
48
+ .slide-number {{
49
+ color: {text_muted};
50
+ font-size: 0.9rem;
51
+ margin-bottom: 1rem;
52
+ padding-bottom: 0.5rem;
53
+ border-bottom: 1px solid {border};
54
+ }}
55
+ .slide-content {{
56
+ flex: 1;
57
+ }}
58
+ h1 {{
59
+ font-size: 2.5rem;
60
+ margin-bottom: 1.5rem;
61
+ color: {primary};
62
+ }}
63
+ h2 {{
64
+ font-size: 2rem;
65
+ margin-bottom: 1.2rem;
66
+ color: {primary};
67
+ }}
68
+ h3 {{
69
+ font-size: 1.5rem;
70
+ margin-bottom: 1rem;
71
+ color: {text};
72
+ }}
73
+ p {{
74
+ font-size: 1.2rem;
75
+ line-height: 1.6;
76
+ margin-bottom: 1rem;
77
+ }}
78
+ ul, ol {{
79
+ margin: 1rem 0;
80
+ padding-left: 2rem;
81
+ }}
82
+ li {{
83
+ font-size: 1.2rem;
84
+ line-height: 1.6;
85
+ margin-bottom: 0.5rem;
86
+ }}
87
+ pre {{
88
+ background: {background};
89
+ border-radius: 4px;
90
+ padding: 1rem;
91
+ overflow-x: auto;
92
+ font-family: 'Fira Code', 'Consolas', monospace;
93
+ font-size: 1rem;
94
+ margin: 1rem 0;
95
+ }}
96
+ code {{
97
+ font-family: 'Fira Code', 'Consolas', monospace;
98
+ background: {background};
99
+ padding: 0.2rem 0.4rem;
100
+ border-radius: 3px;
101
+ font-size: 0.95em;
102
+ }}
103
+ pre code {{
104
+ padding: 0;
105
+ background: none;
106
+ }}
107
+ table {{
108
+ width: 100%;
109
+ border-collapse: collapse;
110
+ margin: 1rem 0;
111
+ }}
112
+ th, td {{
113
+ border: 1px solid {border};
114
+ padding: 0.75rem;
115
+ text-align: left;
116
+ }}
117
+ th {{
118
+ background: {background};
119
+ }}
120
+ blockquote {{
121
+ border-left: 4px solid {primary};
122
+ padding-left: 1rem;
123
+ margin: 1rem 0;
124
+ font-style: italic;
125
+ color: {text_muted};
126
+ }}
127
+ a {{
128
+ color: {primary};
129
+ }}
130
+ img {{
131
+ max-width: 100%;
132
+ height: auto;
133
+ }}
134
+ /* Multi-column layouts */
135
+ .columns {{
136
+ display: flex;
137
+ gap: 2rem;
138
+ align-items: flex-start;
139
+ }}
140
+ .columns > div {{
141
+ flex: 1;
142
+ min-width: 0;
143
+ }}
144
+ .notes {{
145
+ margin-top: 2rem;
146
+ padding: 1rem;
147
+ background: {background};
148
+ border-radius: 4px;
149
+ font-size: 0.9rem;
150
+ color: {text_muted};
151
+ }}
152
+ .notes-title {{
153
+ font-weight: bold;
154
+ margin-bottom: 0.5rem;
155
+ }}
156
+ @media print {{
157
+ .slide {{
158
+ break-inside: avoid;
159
+ page-break-inside: avoid;
160
+ }}
161
+ body {{
162
+ background: white;
163
+ color: black;
164
+ }}
165
+ }}
166
+ </style>
167
+ </head>
168
+ <body>
169
+ <div class="slides">
170
+ {slides}
171
+ </div>
172
+ </body>
173
+ </html>
174
+ """
175
+
176
+ SLIDE_TEMPLATE = """\
177
+ <div class="slide" id="slide-{num}">
178
+ <div class="slide-number">Slide {display_num} of {total}</div>
179
+ <div class="slide-content">
180
+ {content}
181
+ </div>
182
+ {notes}
183
+ </div>
184
+ """
185
+
186
+ NOTES_TEMPLATE = """\
187
+ <div class="notes">
188
+ <div class="notes-title">Presenter Notes</div>
189
+ {notes_content}
190
+ </div>
191
+ """
192
+
193
+
194
+ def render_slide_to_html(content: str) -> str:
195
+ """Convert markdown content to basic HTML.
196
+
197
+ Args:
198
+ content: Markdown content of the slide.
199
+
200
+ Returns:
201
+ HTML string for the slide content.
202
+
203
+ """
204
+ try:
205
+ import markdown # noqa: PLC0415
206
+
207
+ html = markdown.markdown(
208
+ content,
209
+ extensions=["tables", "fenced_code", "codehilite"],
210
+ )
211
+ except ImportError:
212
+ # Fallback: basic markdown-to-html conversion
213
+ import html as html_mod # noqa: PLC0415
214
+
215
+ html = html_mod.escape(content)
216
+ # Basic transformations
217
+ html = html.replace("\n\n", "</p><p>")
218
+ html = f"<p>{html}</p>"
219
+
220
+ return html
221
+
222
+
223
+ def export_to_html(
224
+ source: Path,
225
+ output: Path,
226
+ *,
227
+ theme: str = "dark",
228
+ include_notes: bool = False,
229
+ ) -> Path:
230
+ """Export presentation to HTML.
231
+
232
+ Args:
233
+ source: Path to the markdown presentation.
234
+ output: Path for the output HTML file.
235
+ theme: Theme to use for styling.
236
+ include_notes: Whether to include presenter notes.
237
+
238
+ Returns:
239
+ Path to the created HTML file.
240
+
241
+ Raises:
242
+ ExportError: If export fails.
243
+
244
+ """
245
+ if not source.exists():
246
+ msg = f"Source file not found: {source}"
247
+ raise ExportError(msg)
248
+
249
+ try:
250
+ presentation = parse_presentation(source)
251
+ except Exception as e:
252
+ msg = f"Failed to parse presentation: {e}"
253
+ raise ExportError(msg) from e
254
+
255
+ if presentation.total_slides == 0:
256
+ msg = "Presentation has no slides"
257
+ raise ExportError(msg)
258
+
259
+ theme_obj = get_theme(theme)
260
+
261
+ # Render each slide
262
+ slides_html = []
263
+ for i, slide in enumerate(presentation.slides):
264
+ # Use raw_content and clean with keep_divs=True to preserve column layouts
265
+ slide_content, _ = extract_notes(slide.raw_content)
266
+ cleaned_content = clean_marp_directives(slide_content, keep_divs=True)
267
+ content_html = render_slide_to_html(cleaned_content)
268
+
269
+ # Handle notes
270
+ notes_html = ""
271
+ if include_notes and slide.notes:
272
+ notes_content = render_slide_to_html(slide.notes)
273
+ notes_html = NOTES_TEMPLATE.format(notes_content=notes_content)
274
+
275
+ slide_html = SLIDE_TEMPLATE.format(
276
+ num=i,
277
+ display_num=i + 1,
278
+ total=presentation.total_slides,
279
+ content=content_html,
280
+ notes=notes_html,
281
+ )
282
+ slides_html.append(slide_html)
283
+
284
+ # Build final HTML
285
+ title = presentation.title or source.stem
286
+ html = HTML_TEMPLATE.format(
287
+ title=title,
288
+ background=theme_obj.background,
289
+ surface=theme_obj.surface,
290
+ text=theme_obj.text,
291
+ text_muted=theme_obj.text_muted,
292
+ primary=theme_obj.primary,
293
+ border=theme_obj.text_muted,
294
+ slides="\n".join(slides_html),
295
+ )
296
+
297
+ try:
298
+ output.write_text(html)
299
+ return output
300
+ except Exception as e:
301
+ msg = f"Failed to write HTML: {e}"
302
+ raise ExportError(msg) from e
303
+
304
+
305
+ def run_html_export(
306
+ source: str,
307
+ output: str | None = None,
308
+ *,
309
+ theme: str = "light",
310
+ include_notes: bool = False,
311
+ ) -> int:
312
+ """Run HTML export from command line.
313
+
314
+ Args:
315
+ source: Path to the markdown presentation (string).
316
+ output: Optional path for the output HTML (string).
317
+ theme: Theme to use for styling.
318
+ include_notes: Whether to include presenter notes.
319
+
320
+ Returns:
321
+ Exit code (0 for success).
322
+
323
+ """
324
+ import sys # noqa: PLC0415
325
+
326
+ source_path = Path(source)
327
+ output_path = Path(output) if output else source_path.with_suffix(".html")
328
+
329
+ try:
330
+ result_path = export_to_html(
331
+ source_path,
332
+ output_path,
333
+ theme=theme,
334
+ include_notes=include_notes,
335
+ )
336
+ print(f"Exported to {result_path}")
337
+ return EXIT_SUCCESS
338
+ except ExportError as e:
339
+ print(f"error: {e}", file=sys.stderr)
340
+ return EXIT_FAILURE
prezo/export/images.py ADDED
@@ -0,0 +1,261 @@
1
+ """Image (PNG/SVG) export functionality."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from prezo.parser import parse_presentation
9
+
10
+ from .common import (
11
+ EXIT_FAILURE,
12
+ EXIT_SUCCESS,
13
+ ExportError,
14
+ check_font_availability,
15
+ print_font_warnings,
16
+ )
17
+ from .svg import render_slide_to_svg
18
+
19
+
20
+ def export_slide_to_image(
21
+ content: str,
22
+ slide_num: int,
23
+ total_slides: int,
24
+ output_path: Path,
25
+ *,
26
+ output_format: str = "png",
27
+ theme_name: str = "dark",
28
+ width: int = 80,
29
+ height: int = 24,
30
+ chrome: bool = True,
31
+ scale: float = 1.0,
32
+ ) -> Path:
33
+ """Export a single slide to PNG or SVG.
34
+
35
+ Args:
36
+ content: The markdown content of the slide.
37
+ slide_num: Current slide number (0-indexed).
38
+ total_slides: Total number of slides.
39
+ output_path: Path to save the image.
40
+ output_format: Output format ('png' or 'svg').
41
+ theme_name: Theme to use for rendering.
42
+ width: Console width in characters.
43
+ height: Console height in lines.
44
+ chrome: If True, include window decorations.
45
+ scale: Scale factor for PNG output (e.g., 2.0 for 2x resolution).
46
+
47
+ Returns:
48
+ Path to the created image file.
49
+
50
+ Raises:
51
+ ExportError: If export fails.
52
+
53
+ """
54
+ # Generate SVG
55
+ svg_content = render_slide_to_svg(
56
+ content,
57
+ slide_num,
58
+ total_slides,
59
+ theme_name=theme_name,
60
+ width=width,
61
+ height=height,
62
+ chrome=chrome,
63
+ )
64
+
65
+ if output_format == "svg":
66
+ try:
67
+ output_path.write_text(svg_content)
68
+ return output_path
69
+ except Exception as e:
70
+ msg = f"Failed to write SVG: {e}"
71
+ raise ExportError(msg) from e
72
+
73
+ # Convert SVG to PNG
74
+ try:
75
+ import cairosvg # noqa: PLC0415
76
+ except ImportError as e:
77
+ msg = "PNG export requires cairosvg.\nInstall with: pip install prezo[export]"
78
+ raise ExportError(msg) from e
79
+
80
+ try:
81
+ png_data = cairosvg.svg2png(
82
+ bytestring=svg_content.encode("utf-8"),
83
+ scale=scale,
84
+ )
85
+ if png_data is None:
86
+ msg = "PNG conversion returned no data"
87
+ raise ExportError(msg)
88
+ output_path.write_bytes(png_data)
89
+ return output_path
90
+ except ExportError:
91
+ raise
92
+ except Exception as e:
93
+ msg = f"Failed to convert to PNG: {e}"
94
+ raise ExportError(msg) from e
95
+
96
+
97
+ def export_to_images(
98
+ source: Path,
99
+ output: Path | None = None,
100
+ *,
101
+ output_format: str = "png",
102
+ theme: str = "dark",
103
+ width: int = 80,
104
+ height: int = 24,
105
+ chrome: bool = True,
106
+ slide_num: int | None = None,
107
+ scale: float = 2.0,
108
+ ) -> list[Path]:
109
+ """Export presentation slides to images.
110
+
111
+ Args:
112
+ source: Path to the markdown presentation.
113
+ output: Output path (file for single slide, directory for all).
114
+ output_format: Output format ('png' or 'svg').
115
+ theme: Theme to use for rendering.
116
+ width: Console width in characters.
117
+ height: Console height in lines.
118
+ chrome: If True, include window decorations.
119
+ slide_num: If set, export only this slide (1-indexed).
120
+ scale: Scale factor for PNG output (default 2.0 for higher resolution).
121
+
122
+ Returns:
123
+ List of paths to the created image files.
124
+
125
+ Raises:
126
+ ExportError: If export fails.
127
+
128
+ """
129
+ # Parse presentation
130
+ try:
131
+ presentation = parse_presentation(source)
132
+ except Exception as e:
133
+ msg = f"Failed to read {source}: {e}"
134
+ raise ExportError(msg) from e
135
+
136
+ if presentation.total_slides == 0:
137
+ msg = "No slides found in presentation"
138
+ raise ExportError(msg)
139
+
140
+ # Check font availability and warn if needed (for PNG export)
141
+ if output_format == "png":
142
+ font_warnings = check_font_availability()
143
+ print_font_warnings(font_warnings)
144
+
145
+ # Single slide export
146
+ if slide_num is not None:
147
+ if slide_num < 1 or slide_num > presentation.total_slides:
148
+ msg = (
149
+ f"Invalid slide number: {slide_num}. "
150
+ f"Presentation has {presentation.total_slides} slides."
151
+ )
152
+ raise ExportError(msg)
153
+
154
+ slide_idx = slide_num - 1
155
+ slide = presentation.slides[slide_idx]
156
+
157
+ out_path = Path(output) if output else source.with_suffix(f".{output_format}")
158
+
159
+ result_path = export_slide_to_image(
160
+ slide.content,
161
+ slide_idx,
162
+ presentation.total_slides,
163
+ out_path,
164
+ output_format=output_format,
165
+ theme_name=theme,
166
+ width=width,
167
+ height=height,
168
+ chrome=chrome,
169
+ scale=scale,
170
+ )
171
+ return [result_path]
172
+
173
+ # Export all slides
174
+ if output:
175
+ out_dir = Path(output)
176
+ if out_dir.suffix: # Has extension, treat as file prefix
177
+ prefix = out_dir.stem # Get stem before reassigning
178
+ out_dir = out_dir.parent
179
+ else:
180
+ prefix = source.stem
181
+ else:
182
+ out_dir = source.parent
183
+ prefix = source.stem
184
+
185
+ # Create output directory if needed
186
+ out_dir.mkdir(parents=True, exist_ok=True)
187
+
188
+ exported_paths = []
189
+ for i, slide in enumerate(presentation.slides):
190
+ out_path = out_dir / f"{prefix}_{i + 1:03d}.{output_format}"
191
+ result_path = export_slide_to_image(
192
+ slide.content,
193
+ i,
194
+ presentation.total_slides,
195
+ out_path,
196
+ output_format=output_format,
197
+ theme_name=theme,
198
+ width=width,
199
+ height=height,
200
+ chrome=chrome,
201
+ scale=scale,
202
+ )
203
+ exported_paths.append(result_path)
204
+
205
+ return exported_paths
206
+
207
+
208
+ def run_image_export(
209
+ source: str,
210
+ output: str | None = None,
211
+ *,
212
+ output_format: str = "png",
213
+ theme: str = "dark",
214
+ width: int = 80,
215
+ height: int = 24,
216
+ chrome: bool = True,
217
+ slide_num: int | None = None,
218
+ scale: float = 2.0,
219
+ ) -> int:
220
+ """Run PNG/SVG export from command line.
221
+
222
+ Args:
223
+ source: Path to the markdown presentation.
224
+ output: Optional output path (file or directory).
225
+ output_format: Output format ('png' or 'svg').
226
+ theme: Theme to use for rendering.
227
+ width: Console width in characters.
228
+ height: Console height in lines.
229
+ chrome: If True, include window decorations.
230
+ slide_num: If set, export only this slide (1-indexed).
231
+ scale: Scale factor for PNG output (default 2.0 for higher resolution).
232
+
233
+ Returns:
234
+ Exit code (0 for success).
235
+
236
+ """
237
+ source_path = Path(source)
238
+ output_path = Path(output) if output else None
239
+
240
+ try:
241
+ exported_paths = export_to_images(
242
+ source_path,
243
+ output_path,
244
+ output_format=output_format,
245
+ theme=theme,
246
+ width=width,
247
+ height=height,
248
+ chrome=chrome,
249
+ slide_num=slide_num,
250
+ scale=scale,
251
+ )
252
+ if len(exported_paths) == 1:
253
+ print(f"Exported to {exported_paths[0]}")
254
+ else:
255
+ print(
256
+ f"Exported {len(exported_paths)} slides to {exported_paths[0].parent}/"
257
+ )
258
+ return EXIT_SUCCESS
259
+ except ExportError as e:
260
+ print(f"error: {e}", file=sys.stderr)
261
+ return EXIT_FAILURE