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/layout.py
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"""Layout parsing and rendering for multi-column slides.
|
|
2
|
+
|
|
3
|
+
Supports Pandoc-style fenced div syntax:
|
|
4
|
+
|
|
5
|
+
::: columns
|
|
6
|
+
::: column
|
|
7
|
+
Left content
|
|
8
|
+
:::
|
|
9
|
+
::: column
|
|
10
|
+
Right content
|
|
11
|
+
:::
|
|
12
|
+
:::
|
|
13
|
+
|
|
14
|
+
Additional layout blocks:
|
|
15
|
+
::: center - Horizontally centered content
|
|
16
|
+
::: right - Right-aligned content
|
|
17
|
+
::: spacer [n] - Vertical space (default 1 line)
|
|
18
|
+
::: box [title] - Bordered panel with optional title
|
|
19
|
+
::: divider [style] - Horizontal rule (single/double/thick/dashed)
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from io import StringIO
|
|
28
|
+
from typing import TYPE_CHECKING, Literal
|
|
29
|
+
|
|
30
|
+
from rich.cells import cell_len
|
|
31
|
+
from rich.console import Console, ConsoleOptions, Group, RenderResult
|
|
32
|
+
from rich.markdown import Markdown
|
|
33
|
+
from rich.measure import Measurement
|
|
34
|
+
from rich.panel import Panel
|
|
35
|
+
from rich.rule import Rule
|
|
36
|
+
from rich.text import Text
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from rich.console import RenderableType
|
|
40
|
+
|
|
41
|
+
# -----------------------------------------------------------------------------
|
|
42
|
+
# Data Types
|
|
43
|
+
# -----------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
BlockType = Literal[
|
|
47
|
+
"plain", "columns", "column", "center", "right", "spacer", "box", "divider"
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class LayoutBlock:
|
|
53
|
+
"""A block of content with layout information."""
|
|
54
|
+
|
|
55
|
+
type: BlockType
|
|
56
|
+
content: str = "" # Raw markdown content (for leaf blocks)
|
|
57
|
+
children: list[LayoutBlock] = field(default_factory=list)
|
|
58
|
+
width_percent: int = 0 # For column blocks (0 = auto/equal)
|
|
59
|
+
title: str = "" # For box blocks
|
|
60
|
+
style: str = "" # For divider blocks (single/double/thick/dashed)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# -----------------------------------------------------------------------------
|
|
64
|
+
# Parser
|
|
65
|
+
# -----------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
# Pattern for opening fenced div: ::: type [arg]
|
|
68
|
+
# arg can be: a number (width %), a quoted string (title), or a word (style)
|
|
69
|
+
OPEN_PATTERN = re.compile(r'^:::\s*(\w+)(?:\s+"([^"]+)"|\s+(\S+))?\s*$')
|
|
70
|
+
# Pattern for closing fenced div: :::
|
|
71
|
+
CLOSE_PATTERN = re.compile(r"^:::\s*$")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_layout(content: str) -> list[LayoutBlock]:
|
|
75
|
+
"""Parse markdown content into layout blocks.
|
|
76
|
+
|
|
77
|
+
Detects Pandoc-style fenced divs and builds a tree of LayoutBlocks.
|
|
78
|
+
Content outside fenced divs becomes plain blocks.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
content: Markdown content possibly containing fenced divs.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of LayoutBlock objects representing the content structure.
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
lines = content.split("\n")
|
|
88
|
+
blocks: list[LayoutBlock] = []
|
|
89
|
+
i = 0
|
|
90
|
+
|
|
91
|
+
while i < len(lines):
|
|
92
|
+
line = lines[i]
|
|
93
|
+
match = OPEN_PATTERN.match(line)
|
|
94
|
+
|
|
95
|
+
if match:
|
|
96
|
+
block_type = match.group(1).lower()
|
|
97
|
+
# Group 2 is quoted string, Group 3 is unquoted arg
|
|
98
|
+
quoted_arg = match.group(2) # For "title"
|
|
99
|
+
unquoted_arg = match.group(3) # For width or style
|
|
100
|
+
|
|
101
|
+
# Find matching close and nested content
|
|
102
|
+
block, end_idx = _parse_fenced_block(
|
|
103
|
+
lines, i, block_type, quoted_arg, unquoted_arg
|
|
104
|
+
)
|
|
105
|
+
if block:
|
|
106
|
+
blocks.append(block)
|
|
107
|
+
i = end_idx + 1
|
|
108
|
+
continue
|
|
109
|
+
# Unclosed block - treat as plain text, skip the opening line
|
|
110
|
+
i += 1
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
# Not a fenced div - accumulate plain content
|
|
114
|
+
plain_lines = []
|
|
115
|
+
while i < len(lines):
|
|
116
|
+
if OPEN_PATTERN.match(lines[i]):
|
|
117
|
+
break
|
|
118
|
+
plain_lines.append(lines[i])
|
|
119
|
+
i += 1
|
|
120
|
+
|
|
121
|
+
if plain_lines:
|
|
122
|
+
plain_content = "\n".join(plain_lines).strip()
|
|
123
|
+
if plain_content:
|
|
124
|
+
blocks.append(LayoutBlock(type="plain", content=plain_content))
|
|
125
|
+
|
|
126
|
+
return blocks
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _create_block(
|
|
130
|
+
block_type: str,
|
|
131
|
+
inner_content: str,
|
|
132
|
+
quoted_arg: str | None,
|
|
133
|
+
unquoted_arg: str | None,
|
|
134
|
+
) -> LayoutBlock:
|
|
135
|
+
"""Create a LayoutBlock from parsed fenced div content.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
block_type: The type from ::: type.
|
|
139
|
+
inner_content: Content inside the fenced div.
|
|
140
|
+
quoted_arg: Quoted argument (e.g., title for box).
|
|
141
|
+
unquoted_arg: Unquoted argument (e.g., width or style).
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
A LayoutBlock of the appropriate type.
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
content = inner_content.strip()
|
|
148
|
+
width = int(unquoted_arg) if unquoted_arg and unquoted_arg.isdigit() else 0
|
|
149
|
+
|
|
150
|
+
# Use a dispatch table for simple content blocks
|
|
151
|
+
simple_types = {"center", "right", "plain"}
|
|
152
|
+
|
|
153
|
+
if block_type == "columns":
|
|
154
|
+
block = LayoutBlock(type="columns")
|
|
155
|
+
block.children = parse_layout(inner_content)
|
|
156
|
+
elif block_type == "column":
|
|
157
|
+
block = LayoutBlock(type="column", content=content, width_percent=width)
|
|
158
|
+
elif block_type == "spacer":
|
|
159
|
+
lines_count = width if width > 0 else 1
|
|
160
|
+
block = LayoutBlock(type="spacer", width_percent=lines_count)
|
|
161
|
+
elif block_type == "box":
|
|
162
|
+
title = quoted_arg or unquoted_arg or ""
|
|
163
|
+
block = LayoutBlock(type="box", content=content, title=title)
|
|
164
|
+
elif block_type == "divider":
|
|
165
|
+
style = unquoted_arg if unquoted_arg else "single"
|
|
166
|
+
block = LayoutBlock(type="divider", style=style)
|
|
167
|
+
elif block_type in simple_types:
|
|
168
|
+
block = LayoutBlock(type=block_type, content=content)
|
|
169
|
+
else:
|
|
170
|
+
# Unknown block type - treat as plain
|
|
171
|
+
block = LayoutBlock(type="plain", content=content)
|
|
172
|
+
|
|
173
|
+
return block
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _parse_fenced_block(
|
|
177
|
+
lines: list[str],
|
|
178
|
+
start: int,
|
|
179
|
+
block_type: str,
|
|
180
|
+
quoted_arg: str | None,
|
|
181
|
+
unquoted_arg: str | None,
|
|
182
|
+
) -> tuple[LayoutBlock | None, int]:
|
|
183
|
+
"""Parse a fenced div block starting at the given line.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
lines: All lines of content.
|
|
187
|
+
start: Starting line index (the opening :::).
|
|
188
|
+
block_type: The type from ::: type.
|
|
189
|
+
quoted_arg: Quoted argument (e.g., title for box).
|
|
190
|
+
unquoted_arg: Unquoted argument (e.g., width or style).
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Tuple of (LayoutBlock or None, end line index).
|
|
194
|
+
|
|
195
|
+
"""
|
|
196
|
+
depth = 1
|
|
197
|
+
i = start + 1
|
|
198
|
+
content_lines: list[str] = []
|
|
199
|
+
|
|
200
|
+
while i < len(lines) and depth > 0:
|
|
201
|
+
line = lines[i]
|
|
202
|
+
|
|
203
|
+
if CLOSE_PATTERN.match(line):
|
|
204
|
+
depth -= 1
|
|
205
|
+
if depth == 0:
|
|
206
|
+
break
|
|
207
|
+
content_lines.append(line)
|
|
208
|
+
elif OPEN_PATTERN.match(line):
|
|
209
|
+
depth += 1
|
|
210
|
+
content_lines.append(line)
|
|
211
|
+
else:
|
|
212
|
+
content_lines.append(line)
|
|
213
|
+
i += 1
|
|
214
|
+
|
|
215
|
+
if depth != 0:
|
|
216
|
+
# Unclosed block - treat as plain text
|
|
217
|
+
return None, start
|
|
218
|
+
|
|
219
|
+
inner_content = "\n".join(content_lines)
|
|
220
|
+
block = _create_block(block_type, inner_content, quoted_arg, unquoted_arg)
|
|
221
|
+
return block, i
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def has_layout_blocks(content: str) -> bool:
|
|
225
|
+
"""Check if content contains any layout directives.
|
|
226
|
+
|
|
227
|
+
Quick check to avoid parsing overhead for simple slides.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
content: Markdown content to check.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
True if content contains ::: directives.
|
|
234
|
+
|
|
235
|
+
"""
|
|
236
|
+
return ":::" in content
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# -----------------------------------------------------------------------------
|
|
240
|
+
# Renderer
|
|
241
|
+
# -----------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class ColumnsRenderable:
|
|
245
|
+
"""Rich renderable that displays columns side-by-side."""
|
|
246
|
+
|
|
247
|
+
def __init__(
|
|
248
|
+
self,
|
|
249
|
+
columns: list[LayoutBlock],
|
|
250
|
+
gap: int = 2,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Initialize columns renderable.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
columns: List of column LayoutBlocks.
|
|
256
|
+
gap: Number of spaces between columns.
|
|
257
|
+
|
|
258
|
+
"""
|
|
259
|
+
self.columns = columns
|
|
260
|
+
self.gap = gap
|
|
261
|
+
|
|
262
|
+
def __rich_console__(
|
|
263
|
+
self, console: Console, options: ConsoleOptions
|
|
264
|
+
) -> RenderResult:
|
|
265
|
+
"""Render columns side-by-side."""
|
|
266
|
+
if not self.columns:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
# Blank line before columns
|
|
270
|
+
yield Text("")
|
|
271
|
+
|
|
272
|
+
max_width = options.max_width
|
|
273
|
+
num_cols = len(self.columns)
|
|
274
|
+
|
|
275
|
+
# Calculate column widths
|
|
276
|
+
widths = self._calculate_widths(max_width, num_cols)
|
|
277
|
+
|
|
278
|
+
# Render each column to lines
|
|
279
|
+
column_outputs: list[list[str]] = []
|
|
280
|
+
for col, width in zip(self.columns, widths, strict=False):
|
|
281
|
+
lines = self._render_column(col, width, console)
|
|
282
|
+
column_outputs.append(lines)
|
|
283
|
+
|
|
284
|
+
# Merge columns side-by-side
|
|
285
|
+
merged = self._merge_columns(column_outputs, widths)
|
|
286
|
+
|
|
287
|
+
for line in merged:
|
|
288
|
+
yield Text.from_ansi(line)
|
|
289
|
+
|
|
290
|
+
# Blank line after columns
|
|
291
|
+
yield Text("")
|
|
292
|
+
|
|
293
|
+
def _calculate_widths(self, total_width: int, num_cols: int) -> list[int]:
|
|
294
|
+
"""Calculate width for each column.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
total_width: Total available width.
|
|
298
|
+
num_cols: Number of columns.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
List of widths for each column.
|
|
302
|
+
|
|
303
|
+
"""
|
|
304
|
+
# Account for gaps between columns
|
|
305
|
+
total_gap = self.gap * (num_cols - 1)
|
|
306
|
+
available = total_width - total_gap
|
|
307
|
+
|
|
308
|
+
# Check if any columns have explicit widths
|
|
309
|
+
explicit_widths = [c.width_percent for c in self.columns]
|
|
310
|
+
total_explicit = sum(w for w in explicit_widths if w > 0)
|
|
311
|
+
|
|
312
|
+
if total_explicit > 0:
|
|
313
|
+
# Use explicit percentages
|
|
314
|
+
widths = []
|
|
315
|
+
remaining = available
|
|
316
|
+
auto_count = sum(1 for w in explicit_widths if w == 0)
|
|
317
|
+
|
|
318
|
+
for w in explicit_widths:
|
|
319
|
+
if w > 0:
|
|
320
|
+
col_width = max(1, (available * w) // 100)
|
|
321
|
+
widths.append(col_width)
|
|
322
|
+
remaining -= col_width
|
|
323
|
+
else:
|
|
324
|
+
widths.append(0) # Placeholder
|
|
325
|
+
|
|
326
|
+
# Distribute remaining to auto columns
|
|
327
|
+
if auto_count > 0:
|
|
328
|
+
auto_width = remaining // auto_count
|
|
329
|
+
widths = [w if w > 0 else auto_width for w in widths]
|
|
330
|
+
else:
|
|
331
|
+
# Equal distribution
|
|
332
|
+
col_width = available // num_cols
|
|
333
|
+
widths = [col_width] * num_cols
|
|
334
|
+
|
|
335
|
+
return widths
|
|
336
|
+
|
|
337
|
+
def _render_column(
|
|
338
|
+
self, column: LayoutBlock, width: int, console: Console
|
|
339
|
+
) -> list[str]:
|
|
340
|
+
"""Render a single column to a list of lines.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
column: The column LayoutBlock.
|
|
344
|
+
width: Width in characters.
|
|
345
|
+
console: Rich console for rendering.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
List of rendered lines (with ANSI codes).
|
|
349
|
+
|
|
350
|
+
"""
|
|
351
|
+
# Create a console with fixed width for rendering
|
|
352
|
+
col_console = Console(
|
|
353
|
+
width=width,
|
|
354
|
+
force_terminal=True,
|
|
355
|
+
color_system=console.color_system,
|
|
356
|
+
record=True,
|
|
357
|
+
file=StringIO(),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Render content - check for nested layout blocks
|
|
361
|
+
if column.content:
|
|
362
|
+
if has_layout_blocks(column.content):
|
|
363
|
+
# Parse and render nested layout blocks
|
|
364
|
+
blocks = parse_layout(column.content)
|
|
365
|
+
renderable = render_layout(blocks)
|
|
366
|
+
col_console.print(renderable)
|
|
367
|
+
else:
|
|
368
|
+
# Plain markdown
|
|
369
|
+
md = Markdown(column.content)
|
|
370
|
+
col_console.print(md)
|
|
371
|
+
|
|
372
|
+
# Get rendered lines
|
|
373
|
+
output = col_console.export_text(styles=True)
|
|
374
|
+
lines = output.split("\n")
|
|
375
|
+
|
|
376
|
+
# Ensure each line is padded to column width
|
|
377
|
+
# Note: This is tricky with ANSI codes. For now, we'll do basic padding.
|
|
378
|
+
padded = []
|
|
379
|
+
for line in lines:
|
|
380
|
+
# Strip trailing whitespace but preserve ANSI
|
|
381
|
+
stripped = line.rstrip()
|
|
382
|
+
padded.append(stripped)
|
|
383
|
+
|
|
384
|
+
return padded
|
|
385
|
+
|
|
386
|
+
def _merge_columns(
|
|
387
|
+
self, column_outputs: list[list[str]], widths: list[int]
|
|
388
|
+
) -> list[str]:
|
|
389
|
+
"""Merge column outputs side-by-side.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
column_outputs: List of line lists for each column.
|
|
393
|
+
widths: Width of each column.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Merged lines.
|
|
397
|
+
|
|
398
|
+
"""
|
|
399
|
+
if not column_outputs:
|
|
400
|
+
return []
|
|
401
|
+
|
|
402
|
+
# Find max height
|
|
403
|
+
max_height = max(len(col) for col in column_outputs)
|
|
404
|
+
|
|
405
|
+
# Pad shorter columns
|
|
406
|
+
for col in column_outputs:
|
|
407
|
+
while len(col) < max_height:
|
|
408
|
+
col.append("")
|
|
409
|
+
|
|
410
|
+
# Merge line by line
|
|
411
|
+
result = []
|
|
412
|
+
gap_str = " " * self.gap
|
|
413
|
+
|
|
414
|
+
for row_idx in range(max_height):
|
|
415
|
+
parts = []
|
|
416
|
+
for col_idx, col in enumerate(column_outputs):
|
|
417
|
+
line = col[row_idx] if row_idx < len(col) else ""
|
|
418
|
+
# Pad to column width (accounting for ANSI codes)
|
|
419
|
+
visible_len = _visible_length(line)
|
|
420
|
+
padding = widths[col_idx] - visible_len
|
|
421
|
+
if padding > 0:
|
|
422
|
+
line = line + " " * padding
|
|
423
|
+
parts.append(line)
|
|
424
|
+
|
|
425
|
+
result.append(gap_str.join(parts))
|
|
426
|
+
|
|
427
|
+
return result
|
|
428
|
+
|
|
429
|
+
def __rich_measure__(
|
|
430
|
+
self, console: Console, options: ConsoleOptions
|
|
431
|
+
) -> Measurement:
|
|
432
|
+
"""Return the measurement of this renderable."""
|
|
433
|
+
return Measurement(1, options.max_width)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class CenterRenderable:
|
|
437
|
+
"""Rich renderable that centers content horizontally."""
|
|
438
|
+
|
|
439
|
+
def __init__(self, content: str) -> None:
|
|
440
|
+
"""Initialize center renderable.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
content: Markdown content to center.
|
|
444
|
+
|
|
445
|
+
"""
|
|
446
|
+
self.content = content
|
|
447
|
+
|
|
448
|
+
def __rich_console__(
|
|
449
|
+
self, console: Console, options: ConsoleOptions
|
|
450
|
+
) -> RenderResult:
|
|
451
|
+
"""Render centered content."""
|
|
452
|
+
# Blank line before centered content
|
|
453
|
+
yield Text("")
|
|
454
|
+
|
|
455
|
+
# Use Markdown with center justification
|
|
456
|
+
md = Markdown(self.content, justify="center")
|
|
457
|
+
yield md
|
|
458
|
+
|
|
459
|
+
# Blank line after centered content
|
|
460
|
+
yield Text("")
|
|
461
|
+
|
|
462
|
+
def __rich_measure__(
|
|
463
|
+
self, console: Console, options: ConsoleOptions
|
|
464
|
+
) -> Measurement:
|
|
465
|
+
"""Return the measurement of this renderable."""
|
|
466
|
+
return Measurement(1, options.max_width)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class RightRenderable:
|
|
470
|
+
"""Rich renderable that right-aligns content."""
|
|
471
|
+
|
|
472
|
+
def __init__(self, content: str) -> None:
|
|
473
|
+
"""Initialize right-align renderable.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
content: Markdown content to right-align.
|
|
477
|
+
|
|
478
|
+
"""
|
|
479
|
+
self.content = content
|
|
480
|
+
|
|
481
|
+
def __rich_console__(
|
|
482
|
+
self, console: Console, options: ConsoleOptions
|
|
483
|
+
) -> RenderResult:
|
|
484
|
+
"""Render right-aligned content."""
|
|
485
|
+
yield Text("")
|
|
486
|
+
md = Markdown(self.content, justify="right")
|
|
487
|
+
yield md
|
|
488
|
+
yield Text("")
|
|
489
|
+
|
|
490
|
+
def __rich_measure__(
|
|
491
|
+
self, console: Console, options: ConsoleOptions
|
|
492
|
+
) -> Measurement:
|
|
493
|
+
"""Return the measurement of this renderable."""
|
|
494
|
+
return Measurement(1, options.max_width)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class SpacerRenderable:
|
|
498
|
+
"""Rich renderable that creates vertical space."""
|
|
499
|
+
|
|
500
|
+
def __init__(self, lines: int = 1) -> None:
|
|
501
|
+
"""Initialize spacer renderable.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
lines: Number of blank lines to insert.
|
|
505
|
+
|
|
506
|
+
"""
|
|
507
|
+
self.lines = max(1, lines)
|
|
508
|
+
|
|
509
|
+
def __rich_console__(
|
|
510
|
+
self, console: Console, options: ConsoleOptions
|
|
511
|
+
) -> RenderResult:
|
|
512
|
+
"""Render vertical space."""
|
|
513
|
+
for _ in range(self.lines):
|
|
514
|
+
yield Text("")
|
|
515
|
+
|
|
516
|
+
def __rich_measure__(
|
|
517
|
+
self, console: Console, options: ConsoleOptions
|
|
518
|
+
) -> Measurement:
|
|
519
|
+
"""Return the measurement of this renderable."""
|
|
520
|
+
return Measurement(0, 0)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
class BoxRenderable:
|
|
524
|
+
"""Rich renderable that displays content in a bordered panel."""
|
|
525
|
+
|
|
526
|
+
def __init__(self, content: str, title: str = "") -> None:
|
|
527
|
+
"""Initialize box renderable.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
content: Markdown content to display in the box.
|
|
531
|
+
title: Optional title for the box.
|
|
532
|
+
|
|
533
|
+
"""
|
|
534
|
+
self.content = content
|
|
535
|
+
self.title = title
|
|
536
|
+
|
|
537
|
+
def __rich_console__(
|
|
538
|
+
self, console: Console, options: ConsoleOptions
|
|
539
|
+
) -> RenderResult:
|
|
540
|
+
"""Render content in a bordered panel."""
|
|
541
|
+
yield Text("")
|
|
542
|
+
md = Markdown(self.content)
|
|
543
|
+
panel = Panel(md, title=self.title if self.title else None)
|
|
544
|
+
yield panel
|
|
545
|
+
yield Text("")
|
|
546
|
+
|
|
547
|
+
def __rich_measure__(
|
|
548
|
+
self, console: Console, options: ConsoleOptions
|
|
549
|
+
) -> Measurement:
|
|
550
|
+
"""Return the measurement of this renderable."""
|
|
551
|
+
return Measurement(1, options.max_width)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# Divider style characters
|
|
555
|
+
DIVIDER_STYLES = {
|
|
556
|
+
"single": "─",
|
|
557
|
+
"double": "═",
|
|
558
|
+
"thick": "━",
|
|
559
|
+
"dashed": "╌",
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
class DividerRenderable:
|
|
564
|
+
"""Rich renderable that displays a horizontal rule."""
|
|
565
|
+
|
|
566
|
+
def __init__(self, style: str = "single") -> None:
|
|
567
|
+
"""Initialize divider renderable.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
style: Style of the divider (single, double, thick, dashed).
|
|
571
|
+
|
|
572
|
+
"""
|
|
573
|
+
self.style = style if style in DIVIDER_STYLES else "single"
|
|
574
|
+
|
|
575
|
+
def __rich_console__(
|
|
576
|
+
self, console: Console, options: ConsoleOptions
|
|
577
|
+
) -> RenderResult:
|
|
578
|
+
"""Render horizontal rule."""
|
|
579
|
+
yield Text("")
|
|
580
|
+
char = DIVIDER_STYLES[self.style]
|
|
581
|
+
yield Rule(characters=char)
|
|
582
|
+
yield Text("")
|
|
583
|
+
|
|
584
|
+
def __rich_measure__(
|
|
585
|
+
self, console: Console, options: ConsoleOptions
|
|
586
|
+
) -> Measurement:
|
|
587
|
+
"""Return the measurement of this renderable."""
|
|
588
|
+
return Measurement(1, options.max_width)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _render_block(block: LayoutBlock) -> list[RenderableType]:
|
|
592
|
+
"""Render a single block to Rich renderables.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
block: A LayoutBlock to render.
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
List of Rich renderables for this block.
|
|
599
|
+
|
|
600
|
+
"""
|
|
601
|
+
if block.type == "columns":
|
|
602
|
+
result: list[RenderableType] = []
|
|
603
|
+
columns = [c for c in block.children if c.type == "column"]
|
|
604
|
+
if columns:
|
|
605
|
+
result.append(ColumnsRenderable(columns))
|
|
606
|
+
# Also render any non-column children (plain text between columns)
|
|
607
|
+
for child in block.children:
|
|
608
|
+
if child.type == "plain":
|
|
609
|
+
result.append(Markdown(child.content))
|
|
610
|
+
return result
|
|
611
|
+
|
|
612
|
+
if block.type == "spacer":
|
|
613
|
+
return [SpacerRenderable(block.width_percent)]
|
|
614
|
+
|
|
615
|
+
if block.type == "box":
|
|
616
|
+
return [BoxRenderable(block.content, block.title)]
|
|
617
|
+
|
|
618
|
+
if block.type == "divider":
|
|
619
|
+
return [DividerRenderable(block.style)]
|
|
620
|
+
|
|
621
|
+
# Simple content blocks: plain, center, right, column
|
|
622
|
+
renderable_map: dict[str, type] = {
|
|
623
|
+
"center": CenterRenderable,
|
|
624
|
+
"right": RightRenderable,
|
|
625
|
+
}
|
|
626
|
+
if block.type in renderable_map:
|
|
627
|
+
return [renderable_map[block.type](block.content)]
|
|
628
|
+
|
|
629
|
+
# Default: plain markdown (for "plain" and standalone "column")
|
|
630
|
+
return [Markdown(block.content)]
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def render_layout(
|
|
634
|
+
blocks: list[LayoutBlock],
|
|
635
|
+
) -> RenderableType:
|
|
636
|
+
"""Render layout blocks to a Rich renderable.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
blocks: List of LayoutBlocks from parse_layout().
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
Rich renderable representing the layout.
|
|
643
|
+
|
|
644
|
+
"""
|
|
645
|
+
renderables: list[RenderableType] = []
|
|
646
|
+
|
|
647
|
+
for block in blocks:
|
|
648
|
+
renderables.extend(_render_block(block))
|
|
649
|
+
|
|
650
|
+
if len(renderables) == 1:
|
|
651
|
+
return renderables[0]
|
|
652
|
+
return Group(*renderables)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# -----------------------------------------------------------------------------
|
|
656
|
+
# Utilities
|
|
657
|
+
# -----------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
# ANSI escape sequence pattern
|
|
660
|
+
_ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _visible_length(text: str) -> int:
|
|
664
|
+
"""Calculate visible cell width of text, excluding ANSI codes.
|
|
665
|
+
|
|
666
|
+
Uses Rich's cell_len for proper Unicode width handling:
|
|
667
|
+
- Regular ASCII characters = 1 cell
|
|
668
|
+
- Wide characters (CJK, emoji) = 2 cells
|
|
669
|
+
- Zero-width characters = 0 cells
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
text: Text possibly containing ANSI escape codes.
|
|
673
|
+
|
|
674
|
+
Returns:
|
|
675
|
+
Visible cell width (terminal columns).
|
|
676
|
+
|
|
677
|
+
"""
|
|
678
|
+
# Strip ANSI codes first, then calculate cell width
|
|
679
|
+
clean_text = _ANSI_PATTERN.sub("", text)
|
|
680
|
+
return cell_len(clean_text)
|
prezo/parser.py
CHANGED
|
@@ -346,13 +346,19 @@ def _parse_marp_image_directive(alt_text: str) -> _ImageDirectives:
|
|
|
346
346
|
return result
|
|
347
347
|
|
|
348
348
|
|
|
349
|
-
def clean_marp_directives(content: str) -> str:
|
|
349
|
+
def clean_marp_directives(content: str, *, keep_divs: bool = False) -> str:
|
|
350
350
|
"""Remove MARP-specific directives that don't render in TUI.
|
|
351
351
|
|
|
352
352
|
Cleans up:
|
|
353
353
|
- MARP HTML comments (<!-- _class: ... -->, <!-- _header: ... -->, etc.)
|
|
354
354
|
- MARP image directives (![bg ...])
|
|
355
355
|
- Empty HTML divs with only styling
|
|
356
|
+
- All HTML divs (unless keep_divs=True for HTML export)
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
content: Slide content to clean.
|
|
360
|
+
keep_divs: If True, preserve structural divs (for HTML export).
|
|
361
|
+
|
|
356
362
|
"""
|
|
357
363
|
# Remove MARP directive comments
|
|
358
364
|
content = re.sub(r"<!--\s*_\w+:.*?-->\s*\n?", "", content)
|
|
@@ -363,9 +369,10 @@ def clean_marp_directives(content: str) -> str:
|
|
|
363
369
|
# Remove empty divs with only style attributes
|
|
364
370
|
content = re.sub(r'<div[^>]*style="[^"]*"[^>]*>\s*</div>\s*\n?', "", content)
|
|
365
371
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
372
|
+
if not keep_divs:
|
|
373
|
+
# Remove inline HTML divs (keep the content) - for TUI display
|
|
374
|
+
content = re.sub(r"<div[^>]*>\s*\n?", "", content)
|
|
375
|
+
content = re.sub(r"\s*</div>", "", content)
|
|
369
376
|
|
|
370
377
|
# Clean up multiple blank lines
|
|
371
378
|
return re.sub(r"\n{3,}", "\n\n", content)
|