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/__init__.py +20 -1
- prezo/app.py +295 -43
- prezo/config.py +2 -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 +360 -53
- prezo/parser.py +29 -0
- prezo/widgets/status_bar.py +20 -2
- {prezo-2026.1.2.dist-info → prezo-2026.1.4.dist-info}/METADATA +1 -1
- {prezo-2026.1.2.dist-info → prezo-2026.1.4.dist-info}/RECORD +16 -11
- prezo/export.py +0 -860
- {prezo-2026.1.2.dist-info → prezo-2026.1.4.dist-info}/WHEEL +0 -0
- {prezo-2026.1.2.dist-info → prezo-2026.1.4.dist-info}/entry_points.txt +0 -0
prezo/layout.py
CHANGED
|
@@ -11,6 +11,13 @@ Supports Pandoc-style fenced div syntax:
|
|
|
11
11
|
:::
|
|
12
12
|
:::
|
|
13
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
|
+
|
|
14
21
|
"""
|
|
15
22
|
|
|
16
23
|
from __future__ import annotations
|
|
@@ -20,9 +27,12 @@ from dataclasses import dataclass, field
|
|
|
20
27
|
from io import StringIO
|
|
21
28
|
from typing import TYPE_CHECKING, Literal
|
|
22
29
|
|
|
30
|
+
from rich.cells import cell_len
|
|
23
31
|
from rich.console import Console, ConsoleOptions, Group, RenderResult
|
|
24
32
|
from rich.markdown import Markdown
|
|
25
33
|
from rich.measure import Measurement
|
|
34
|
+
from rich.panel import Panel
|
|
35
|
+
from rich.rule import Rule
|
|
26
36
|
from rich.text import Text
|
|
27
37
|
|
|
28
38
|
if TYPE_CHECKING:
|
|
@@ -33,22 +43,30 @@ if TYPE_CHECKING:
|
|
|
33
43
|
# -----------------------------------------------------------------------------
|
|
34
44
|
|
|
35
45
|
|
|
46
|
+
BlockType = Literal[
|
|
47
|
+
"plain", "columns", "column", "center", "right", "spacer", "box", "divider"
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
36
51
|
@dataclass
|
|
37
52
|
class LayoutBlock:
|
|
38
53
|
"""A block of content with layout information."""
|
|
39
54
|
|
|
40
|
-
type:
|
|
55
|
+
type: BlockType
|
|
41
56
|
content: str = "" # Raw markdown content (for leaf blocks)
|
|
42
57
|
children: list[LayoutBlock] = field(default_factory=list)
|
|
43
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)
|
|
44
61
|
|
|
45
62
|
|
|
46
63
|
# -----------------------------------------------------------------------------
|
|
47
64
|
# Parser
|
|
48
65
|
# -----------------------------------------------------------------------------
|
|
49
66
|
|
|
50
|
-
# Pattern for opening fenced div: ::: type [
|
|
51
|
-
|
|
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*$')
|
|
52
70
|
# Pattern for closing fenced div: :::
|
|
53
71
|
CLOSE_PATTERN = re.compile(r"^:::\s*$")
|
|
54
72
|
|
|
@@ -76,10 +94,14 @@ def parse_layout(content: str) -> list[LayoutBlock]:
|
|
|
76
94
|
|
|
77
95
|
if match:
|
|
78
96
|
block_type = match.group(1).lower()
|
|
79
|
-
|
|
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
|
|
80
100
|
|
|
81
101
|
# Find matching close and nested content
|
|
82
|
-
block, end_idx = _parse_fenced_block(
|
|
102
|
+
block, end_idx = _parse_fenced_block(
|
|
103
|
+
lines, i, block_type, quoted_arg, unquoted_arg
|
|
104
|
+
)
|
|
83
105
|
if block:
|
|
84
106
|
blocks.append(block)
|
|
85
107
|
i = end_idx + 1
|
|
@@ -104,8 +126,59 @@ def parse_layout(content: str) -> list[LayoutBlock]:
|
|
|
104
126
|
return blocks
|
|
105
127
|
|
|
106
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
|
+
|
|
107
176
|
def _parse_fenced_block(
|
|
108
|
-
lines: list[str],
|
|
177
|
+
lines: list[str],
|
|
178
|
+
start: int,
|
|
179
|
+
block_type: str,
|
|
180
|
+
quoted_arg: str | None,
|
|
181
|
+
unquoted_arg: str | None,
|
|
109
182
|
) -> tuple[LayoutBlock | None, int]:
|
|
110
183
|
"""Parse a fenced div block starting at the given line.
|
|
111
184
|
|
|
@@ -113,7 +186,8 @@ def _parse_fenced_block(
|
|
|
113
186
|
lines: All lines of content.
|
|
114
187
|
start: Starting line index (the opening :::).
|
|
115
188
|
block_type: The type from ::: type.
|
|
116
|
-
|
|
189
|
+
quoted_arg: Quoted argument (e.g., title for box).
|
|
190
|
+
unquoted_arg: Unquoted argument (e.g., width or style).
|
|
117
191
|
|
|
118
192
|
Returns:
|
|
119
193
|
Tuple of (LayoutBlock or None, end line index).
|
|
@@ -143,28 +217,8 @@ def _parse_fenced_block(
|
|
|
143
217
|
return None, start
|
|
144
218
|
|
|
145
219
|
inner_content = "\n".join(content_lines)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if block_type in ("columns", "column", "center"):
|
|
149
|
-
block = LayoutBlock(
|
|
150
|
-
type=block_type, # type: ignore[arg-type]
|
|
151
|
-
width_percent=width,
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
if block_type == "columns":
|
|
155
|
-
# Parse children (should be column blocks)
|
|
156
|
-
block.children = parse_layout(inner_content)
|
|
157
|
-
elif block_type == "column":
|
|
158
|
-
# Column contains markdown content, but might have nested structure
|
|
159
|
-
# For now, treat as plain content
|
|
160
|
-
block.content = inner_content.strip()
|
|
161
|
-
elif block_type == "center":
|
|
162
|
-
block.content = inner_content.strip()
|
|
163
|
-
|
|
164
|
-
return block, i
|
|
165
|
-
|
|
166
|
-
# Unknown block type - treat content as plain
|
|
167
|
-
return LayoutBlock(type="plain", content=inner_content.strip()), i
|
|
220
|
+
block = _create_block(block_type, inner_content, quoted_arg, unquoted_arg)
|
|
221
|
+
return block, i
|
|
168
222
|
|
|
169
223
|
|
|
170
224
|
def has_layout_blocks(content: str) -> bool:
|
|
@@ -303,10 +357,17 @@ class ColumnsRenderable:
|
|
|
303
357
|
file=StringIO(),
|
|
304
358
|
)
|
|
305
359
|
|
|
306
|
-
# Render
|
|
360
|
+
# Render content - check for nested layout blocks
|
|
307
361
|
if column.content:
|
|
308
|
-
|
|
309
|
-
|
|
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)
|
|
310
371
|
|
|
311
372
|
# Get rendered lines
|
|
312
373
|
output = col_console.export_text(styles=True)
|
|
@@ -405,6 +466,168 @@ class CenterRenderable:
|
|
|
405
466
|
return Measurement(1, options.max_width)
|
|
406
467
|
|
|
407
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
|
+
content = _render_box_content(self.content)
|
|
542
|
+
panel = Panel(content, title=self.title if self.title else None)
|
|
543
|
+
yield panel
|
|
544
|
+
|
|
545
|
+
def __rich_measure__(
|
|
546
|
+
self, console: Console, options: ConsoleOptions
|
|
547
|
+
) -> Measurement:
|
|
548
|
+
"""Return the measurement of this renderable."""
|
|
549
|
+
return Measurement(1, options.max_width)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# Divider style characters
|
|
553
|
+
DIVIDER_STYLES = {
|
|
554
|
+
"single": "─",
|
|
555
|
+
"double": "═",
|
|
556
|
+
"thick": "━",
|
|
557
|
+
"dashed": "╌",
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class DividerRenderable:
|
|
562
|
+
"""Rich renderable that displays a horizontal rule."""
|
|
563
|
+
|
|
564
|
+
def __init__(self, style: str = "single") -> None:
|
|
565
|
+
"""Initialize divider renderable.
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
style: Style of the divider (single, double, thick, dashed).
|
|
569
|
+
|
|
570
|
+
"""
|
|
571
|
+
self.style = style if style in DIVIDER_STYLES else "single"
|
|
572
|
+
|
|
573
|
+
def __rich_console__(
|
|
574
|
+
self, console: Console, options: ConsoleOptions
|
|
575
|
+
) -> RenderResult:
|
|
576
|
+
"""Render horizontal rule."""
|
|
577
|
+
yield Text("")
|
|
578
|
+
char = DIVIDER_STYLES[self.style]
|
|
579
|
+
yield Rule(characters=char)
|
|
580
|
+
yield Text("")
|
|
581
|
+
|
|
582
|
+
def __rich_measure__(
|
|
583
|
+
self, console: Console, options: ConsoleOptions
|
|
584
|
+
) -> Measurement:
|
|
585
|
+
"""Return the measurement of this renderable."""
|
|
586
|
+
return Measurement(1, options.max_width)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _render_block(block: LayoutBlock) -> list[RenderableType]:
|
|
590
|
+
"""Render a single block to Rich renderables.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
block: A LayoutBlock to render.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
List of Rich renderables for this block.
|
|
597
|
+
|
|
598
|
+
"""
|
|
599
|
+
if block.type == "columns":
|
|
600
|
+
result: list[RenderableType] = []
|
|
601
|
+
columns = [c for c in block.children if c.type == "column"]
|
|
602
|
+
if columns:
|
|
603
|
+
result.append(ColumnsRenderable(columns))
|
|
604
|
+
# Also render any non-column children (plain text between columns)
|
|
605
|
+
for child in block.children:
|
|
606
|
+
if child.type == "plain":
|
|
607
|
+
result.append(Markdown(child.content))
|
|
608
|
+
return result
|
|
609
|
+
|
|
610
|
+
if block.type == "spacer":
|
|
611
|
+
return [SpacerRenderable(block.width_percent)]
|
|
612
|
+
|
|
613
|
+
if block.type == "box":
|
|
614
|
+
return [BoxRenderable(block.content, block.title)]
|
|
615
|
+
|
|
616
|
+
if block.type == "divider":
|
|
617
|
+
return [DividerRenderable(block.style)]
|
|
618
|
+
|
|
619
|
+
# Simple content blocks: plain, center, right, column
|
|
620
|
+
renderable_map: dict[str, type] = {
|
|
621
|
+
"center": CenterRenderable,
|
|
622
|
+
"right": RightRenderable,
|
|
623
|
+
}
|
|
624
|
+
if block.type in renderable_map:
|
|
625
|
+
return [renderable_map[block.type](block.content)]
|
|
626
|
+
|
|
627
|
+
# Default: plain markdown (for "plain" and standalone "column")
|
|
628
|
+
return [Markdown(block.content)]
|
|
629
|
+
|
|
630
|
+
|
|
408
631
|
def render_layout(
|
|
409
632
|
blocks: list[LayoutBlock],
|
|
410
633
|
) -> RenderableType:
|
|
@@ -419,24 +642,11 @@ def render_layout(
|
|
|
419
642
|
"""
|
|
420
643
|
renderables: list[RenderableType] = []
|
|
421
644
|
|
|
422
|
-
for block in blocks:
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
# Filter to only column children
|
|
428
|
-
columns = [c for c in block.children if c.type == "column"]
|
|
429
|
-
if columns:
|
|
430
|
-
renderables.append(ColumnsRenderable(columns))
|
|
431
|
-
# Also render any non-column children (plain text between columns)
|
|
432
|
-
for child in block.children:
|
|
433
|
-
if child.type == "plain":
|
|
434
|
-
renderables.append(Markdown(child.content))
|
|
435
|
-
case "center":
|
|
436
|
-
renderables.append(CenterRenderable(block.content))
|
|
437
|
-
case "column":
|
|
438
|
-
# Standalone column (shouldn't happen normally)
|
|
439
|
-
renderables.append(Markdown(block.content))
|
|
645
|
+
for i, block in enumerate(blocks):
|
|
646
|
+
# Add spacing before box blocks (except the first one)
|
|
647
|
+
if block.type == "box" and i > 0:
|
|
648
|
+
renderables.append(Text(""))
|
|
649
|
+
renderables.extend(_render_block(block))
|
|
440
650
|
|
|
441
651
|
if len(renderables) == 1:
|
|
442
652
|
return renderables[0]
|
|
@@ -447,18 +657,115 @@ def render_layout(
|
|
|
447
657
|
# Utilities
|
|
448
658
|
# -----------------------------------------------------------------------------
|
|
449
659
|
|
|
660
|
+
# Pattern for **bold** text
|
|
661
|
+
_BOLD_PATTERN = re.compile(r"\*\*(.+?)\*\*")
|
|
662
|
+
|
|
663
|
+
# Pattern for bullet list items
|
|
664
|
+
_BULLET_PATTERN = re.compile(r"^[-*+]\s+(.*)$")
|
|
665
|
+
|
|
666
|
+
# Pattern for numbered list items
|
|
667
|
+
_NUMBERED_PATTERN = re.compile(r"^(\d+\.)\s+(.*)$")
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _parse_inline_formatting(text: str) -> Text:
|
|
671
|
+
"""Parse inline markdown formatting like **bold**.
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
text: Text with possible **bold** markers.
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
Rich Text object with appropriate styling.
|
|
678
|
+
|
|
679
|
+
"""
|
|
680
|
+
result = Text()
|
|
681
|
+
last_end = 0
|
|
682
|
+
|
|
683
|
+
for match in _BOLD_PATTERN.finditer(text):
|
|
684
|
+
# Add text before match
|
|
685
|
+
if match.start() > last_end:
|
|
686
|
+
result.append(text[last_end : match.start()])
|
|
687
|
+
# Add bold text
|
|
688
|
+
result.append(match.group(1), style="bold")
|
|
689
|
+
last_end = match.end()
|
|
690
|
+
|
|
691
|
+
# Add remaining text
|
|
692
|
+
if last_end < len(text):
|
|
693
|
+
result.append(text[last_end:])
|
|
694
|
+
|
|
695
|
+
return result
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _render_box_content(content: str) -> Text:
|
|
699
|
+
"""Render box content with compact spacing.
|
|
700
|
+
|
|
701
|
+
Handles the common pattern of a title line followed by a bullet list,
|
|
702
|
+
without the extra blank line that Rich's Markdown adds.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
content: Markdown content for the box.
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
Rich Text object with compact formatting.
|
|
709
|
+
|
|
710
|
+
"""
|
|
711
|
+
lines = content.strip().split("\n")
|
|
712
|
+
if not lines:
|
|
713
|
+
return Text()
|
|
714
|
+
|
|
715
|
+
result = Text()
|
|
716
|
+
|
|
717
|
+
for i, line in enumerate(lines):
|
|
718
|
+
stripped = line.strip()
|
|
719
|
+
|
|
720
|
+
if not stripped:
|
|
721
|
+
# Blank line - add single newline
|
|
722
|
+
if i > 0:
|
|
723
|
+
result.append("\n")
|
|
724
|
+
continue
|
|
725
|
+
|
|
726
|
+
# Add newline before this line (except first)
|
|
727
|
+
if i > 0 and result.plain and not result.plain.endswith("\n"):
|
|
728
|
+
result.append("\n")
|
|
729
|
+
|
|
730
|
+
# Check for bullet list item
|
|
731
|
+
bullet_match = _BULLET_PATTERN.match(stripped)
|
|
732
|
+
if bullet_match:
|
|
733
|
+
result.append(" • ")
|
|
734
|
+
result.append(_parse_inline_formatting(bullet_match.group(1)))
|
|
735
|
+
continue
|
|
736
|
+
|
|
737
|
+
# Check for numbered list item
|
|
738
|
+
numbered_match = _NUMBERED_PATTERN.match(stripped)
|
|
739
|
+
if numbered_match:
|
|
740
|
+
result.append(f" {numbered_match.group(1)} ")
|
|
741
|
+
result.append(_parse_inline_formatting(numbered_match.group(2)))
|
|
742
|
+
continue
|
|
743
|
+
|
|
744
|
+
# Regular text line
|
|
745
|
+
result.append(_parse_inline_formatting(stripped))
|
|
746
|
+
|
|
747
|
+
return result
|
|
748
|
+
|
|
749
|
+
|
|
450
750
|
# ANSI escape sequence pattern
|
|
451
751
|
_ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
|
|
452
752
|
|
|
453
753
|
|
|
454
754
|
def _visible_length(text: str) -> int:
|
|
455
|
-
"""Calculate visible
|
|
755
|
+
"""Calculate visible cell width of text, excluding ANSI codes.
|
|
756
|
+
|
|
757
|
+
Uses Rich's cell_len for proper Unicode width handling:
|
|
758
|
+
- Regular ASCII characters = 1 cell
|
|
759
|
+
- Wide characters (CJK, emoji) = 2 cells
|
|
760
|
+
- Zero-width characters = 0 cells
|
|
456
761
|
|
|
457
762
|
Args:
|
|
458
763
|
text: Text possibly containing ANSI escape codes.
|
|
459
764
|
|
|
460
765
|
Returns:
|
|
461
|
-
Visible
|
|
766
|
+
Visible cell width (terminal columns).
|
|
462
767
|
|
|
463
768
|
"""
|
|
464
|
-
|
|
769
|
+
# Strip ANSI codes first, then calculate cell width
|
|
770
|
+
clean_text = _ANSI_PATTERN.sub("", text)
|
|
771
|
+
return cell_len(clean_text)
|
prezo/parser.py
CHANGED
|
@@ -40,6 +40,7 @@ class Slide:
|
|
|
40
40
|
raw_content: str = "" # Original content for editing
|
|
41
41
|
notes: str = ""
|
|
42
42
|
images: list[ImageRef] = field(default_factory=list)
|
|
43
|
+
incremental: bool | None = None # Per-slide override for incremental lists
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
@dataclass
|
|
@@ -51,6 +52,7 @@ class PresentationConfig:
|
|
|
51
52
|
show_elapsed: bool | None = None
|
|
52
53
|
countdown_minutes: int | None = None
|
|
53
54
|
image_mode: str | None = None
|
|
55
|
+
incremental_lists: bool | None = None
|
|
54
56
|
|
|
55
57
|
def merge_to_dict(self) -> dict[str, Any]:
|
|
56
58
|
"""Convert non-None values to a config dict for merging."""
|
|
@@ -65,6 +67,10 @@ class PresentationConfig:
|
|
|
65
67
|
result.setdefault("timer", {})["countdown_minutes"] = self.countdown_minutes
|
|
66
68
|
if self.image_mode is not None:
|
|
67
69
|
result.setdefault("images", {})["mode"] = self.image_mode
|
|
70
|
+
if self.incremental_lists is not None:
|
|
71
|
+
result.setdefault("behavior", {})["incremental_lists"] = (
|
|
72
|
+
self.incremental_lists
|
|
73
|
+
)
|
|
68
74
|
return result
|
|
69
75
|
|
|
70
76
|
|
|
@@ -170,6 +176,24 @@ def extract_notes(content: str) -> tuple[str, str]:
|
|
|
170
176
|
return content, ""
|
|
171
177
|
|
|
172
178
|
|
|
179
|
+
def extract_slide_incremental(content: str) -> bool | None:
|
|
180
|
+
"""Extract per-slide incremental directive from content.
|
|
181
|
+
|
|
182
|
+
Looks for:
|
|
183
|
+
- <!-- incremental --> to enable incremental lists for this slide
|
|
184
|
+
- <!-- no-incremental --> to disable incremental lists for this slide
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
True if incremental enabled, False if disabled, None if not specified.
|
|
188
|
+
|
|
189
|
+
"""
|
|
190
|
+
if re.search(r"<!--\s*incremental\s*-->", content, re.IGNORECASE):
|
|
191
|
+
return True
|
|
192
|
+
if re.search(r"<!--\s*no-incremental\s*-->", content, re.IGNORECASE):
|
|
193
|
+
return False
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
173
197
|
def extract_prezo_directives(content: str) -> PresentationConfig:
|
|
174
198
|
"""Extract Prezo-specific directives from presentation content.
|
|
175
199
|
|
|
@@ -217,6 +241,8 @@ def extract_prezo_directives(content: str) -> PresentationConfig:
|
|
|
217
241
|
config.countdown_minutes = int(value)
|
|
218
242
|
elif key in ("image_mode", "imagemode", "images"):
|
|
219
243
|
config.image_mode = value
|
|
244
|
+
elif key in ("incremental_lists", "incremental", "incrementallists"):
|
|
245
|
+
config.incremental_lists = value.lower() in ("true", "1", "yes", "on")
|
|
220
246
|
|
|
221
247
|
return config
|
|
222
248
|
|
|
@@ -420,6 +446,8 @@ def _parse_content(text: str, source_path: Path | None) -> Presentation:
|
|
|
420
446
|
slide_content, notes = extract_notes(raw_slide)
|
|
421
447
|
# Extract images BEFORE cleaning (clean_marp_directives removes bg images)
|
|
422
448
|
images = extract_images(slide_content)
|
|
449
|
+
# Extract per-slide incremental setting
|
|
450
|
+
incremental = extract_slide_incremental(slide_content)
|
|
423
451
|
cleaned_content = clean_marp_directives(slide_content).strip()
|
|
424
452
|
slide = Slide(
|
|
425
453
|
content=cleaned_content,
|
|
@@ -427,6 +455,7 @@ def _parse_content(text: str, source_path: Path | None) -> Presentation:
|
|
|
427
455
|
raw_content=raw_slide,
|
|
428
456
|
notes=notes.strip(),
|
|
429
457
|
images=images,
|
|
458
|
+
incremental=incremental,
|
|
430
459
|
)
|
|
431
460
|
presentation.slides.append(slide)
|
|
432
461
|
|
prezo/widgets/status_bar.py
CHANGED
|
@@ -56,6 +56,9 @@ class StatusBar(Static):
|
|
|
56
56
|
show_elapsed: reactive[bool] = reactive(True)
|
|
57
57
|
show_countdown: reactive[bool] = reactive(False)
|
|
58
58
|
countdown_minutes: reactive[int] = reactive(0)
|
|
59
|
+
# Incremental reveal indicator
|
|
60
|
+
reveal_current: reactive[int] = reactive(-1) # -1 = not in reveal mode
|
|
61
|
+
reveal_total: reactive[int] = reactive(0)
|
|
59
62
|
|
|
60
63
|
def __init__(self, **kwargs) -> None:
|
|
61
64
|
"""Initialize the status bar."""
|
|
@@ -78,6 +81,13 @@ class StatusBar(Static):
|
|
|
78
81
|
bar = format_progress_bar(self.current, self.total, width=20)
|
|
79
82
|
progress = f"{bar} {self.current + 1}/{self.total}"
|
|
80
83
|
|
|
84
|
+
# Reveal indicator (shows remaining list items)
|
|
85
|
+
reveal = ""
|
|
86
|
+
if self.reveal_current >= 0 and self.reveal_total > 0:
|
|
87
|
+
remaining = self.reveal_total - self.reveal_current - 1
|
|
88
|
+
if remaining > 0:
|
|
89
|
+
reveal = f" [+{remaining}]"
|
|
90
|
+
|
|
81
91
|
# Clock part
|
|
82
92
|
clock_parts = []
|
|
83
93
|
if self.show_clock:
|
|
@@ -100,8 +110,8 @@ class StatusBar(Static):
|
|
|
100
110
|
|
|
101
111
|
# Combine with spacing
|
|
102
112
|
if clock:
|
|
103
|
-
return f" {progress} {clock} "
|
|
104
|
-
return f" {progress} "
|
|
113
|
+
return f" {progress}{reveal} {clock} "
|
|
114
|
+
return f" {progress}{reveal} "
|
|
105
115
|
|
|
106
116
|
def reset_timer(self) -> None:
|
|
107
117
|
"""Reset the elapsed timer."""
|
|
@@ -142,6 +152,14 @@ class StatusBar(Static):
|
|
|
142
152
|
"""React to elapsed time visibility changes."""
|
|
143
153
|
self.refresh()
|
|
144
154
|
|
|
155
|
+
def watch_reveal_current(self, value: int) -> None:
|
|
156
|
+
"""React to reveal indicator changes."""
|
|
157
|
+
self.refresh()
|
|
158
|
+
|
|
159
|
+
def watch_reveal_total(self, value: int) -> None:
|
|
160
|
+
"""React to reveal total changes."""
|
|
161
|
+
self.refresh()
|
|
162
|
+
|
|
145
163
|
|
|
146
164
|
# Keep these for backwards compatibility and separate use
|
|
147
165
|
class ProgressBar(Static):
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
prezo/__init__.py,sha256=
|
|
2
|
-
prezo/app.py,sha256=
|
|
3
|
-
prezo/config.py,sha256=
|
|
4
|
-
prezo/export.py,sha256=
|
|
1
|
+
prezo/__init__.py,sha256=F91cPBSScY8J-ypfjhqSQ6vieknutK9ZreSa4jCT0Rc,7006
|
|
2
|
+
prezo/app.py,sha256=ujWjq1UKH8wjlbu1r5v78T8QOfMI_MruPvCJZpdAGIQ,41278
|
|
3
|
+
prezo/config.py,sha256=dVvXrcyDWmmYLt7otDrU386A6fJ2vAoVlCkRVLpJ3I8,6718
|
|
4
|
+
prezo/export/__init__.py,sha256=jdf4Xu71aUKPBXUt8k8TEUzgMee4bfEh71iJGZXtVKE,1010
|
|
5
|
+
prezo/export/common.py,sha256=W9Gn2_wjd3EPuenAECrs6pqsJEWFBJHtGs8y6-4VEKQ,2451
|
|
6
|
+
prezo/export/html.py,sha256=GLRjTvZUEmvee6F2NlU3sn94H47PqLTLpaC3Ab_kxo8,8787
|
|
7
|
+
prezo/export/images.py,sha256=TNTGIYNRTxwVjbHqZXYjIj0gouV_Dy1_DtenMlxsquE,7410
|
|
8
|
+
prezo/export/pdf.py,sha256=9rzybk8o1vEu_JwvKB2abzrYoKfpEA8IP5pEFJHF5WM,13942
|
|
9
|
+
prezo/export/svg.py,sha256=gxhRXpLiKcVr4NSF8nwaid74p8f9dvj4RwXwvMP0EbU,5698
|
|
5
10
|
prezo/images/__init__.py,sha256=xrWSR3z0SXYpLtjIvR2VOMxiJGkxEsls5-EOs9GecFA,324
|
|
6
11
|
prezo/images/ascii.py,sha256=aNz02jN4rkDw0WzmkGDrAGw1R1dY5QGREvIIPI6jwow,7613
|
|
7
12
|
prezo/images/base.py,sha256=STuS57AVSJ2lzwyn0QrIceGaSd2IWEiLGN-elT3u3AM,2749
|
|
@@ -11,8 +16,8 @@ prezo/images/kitty.py,sha256=mWR-tIE_WDP5BjOkQydPpxWBBGNaZL8PkMICesWQid8,10883
|
|
|
11
16
|
prezo/images/overlay.py,sha256=lWIAvINxZrKembtB0gzWWaUoedNt7beFU4OhErfwWaw,9600
|
|
12
17
|
prezo/images/processor.py,sha256=zMcfwltecup_AX2FhUIlPdO7c87a9jw7P9tLTIkr54U,4069
|
|
13
18
|
prezo/images/sixel.py,sha256=2IeKDiMsWU1Tn3HYI3PC972ygxKGqpfz6tnhQcM_sVM,5604
|
|
14
|
-
prezo/layout.py,sha256=
|
|
15
|
-
prezo/parser.py,sha256=
|
|
19
|
+
prezo/layout.py,sha256=FN-nMX902jx9MA7TnySbPskmnyWAiziv_xBvTTYrFd0,22463
|
|
20
|
+
prezo/parser.py,sha256=TY4btwASo45EhuZ62AU5mb2Ky_eo2dNL_LfQValWdWc,16247
|
|
16
21
|
prezo/screens/__init__.py,sha256=xHG9jNJz4vi1tpneSEVlD0D9I0M2U4gAGk6-R9xbUf4,508
|
|
17
22
|
prezo/screens/base.py,sha256=2n6Uj8evfIbcpn4AVYNG5iM_k7rIJ3Vwmor_xrQPU9E,2057
|
|
18
23
|
prezo/screens/blackout.py,sha256=wPSdD9lgu8ykAIQjU1OesnmjQoQEn9BdC4TEpABYxW4,1640
|
|
@@ -27,8 +32,8 @@ prezo/widgets/__init__.py,sha256=6qAbVVWG2fb4DLv0EzMQ-Qbi74SviXIo-7D7DyDwnrI,378
|
|
|
27
32
|
prezo/widgets/image_display.py,sha256=8IKncaoC2iWebmJQp_QomF7UVgRxD4WThOshN1Nht2M,3361
|
|
28
33
|
prezo/widgets/slide_button.py,sha256=g5mvtCZSorTIZp_PXgHYeYeeCSNFy0pW3K7iDlZu7yA,2012
|
|
29
34
|
prezo/widgets/slide_content.py,sha256=AO3doIuPBSo5vec_d1xE5F8YMJWxzOq5IFO7gSYSWNw,2300
|
|
30
|
-
prezo/widgets/status_bar.py,sha256=
|
|
31
|
-
prezo-2026.1.
|
|
32
|
-
prezo-2026.1.
|
|
33
|
-
prezo-2026.1.
|
|
34
|
-
prezo-2026.1.
|
|
35
|
+
prezo/widgets/status_bar.py,sha256=RuyH_HeGfoecTzS3QhORwV2ahnKjhVDmGZZZrb_jm7k,8843
|
|
36
|
+
prezo-2026.1.4.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
37
|
+
prezo-2026.1.4.dist-info/entry_points.txt,sha256=74ShZJ_EKjzi63JyPynVnc0uCHGNjIWjAVs8vU_qTyA,38
|
|
38
|
+
prezo-2026.1.4.dist-info/METADATA,sha256=UvctCI2bOVGIgoHLdhl7CSgDNNiiN6nTREg8s-FyQQY,5320
|
|
39
|
+
prezo-2026.1.4.dist-info/RECORD,,
|