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/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
- # Remove inline HTML divs (keep the content)
367
- content = re.sub(r"<div[^>]*>\s*\n?", "", content)
368
- content = re.sub(r"\s*</div>", "", content)
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)