prezo 0.3.2__tar.gz → 2026.1.2__tar.gz
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-0.3.2 → prezo-2026.1.2}/PKG-INFO +26 -3
- {prezo-0.3.2 → prezo-2026.1.2}/README.md +25 -2
- {prezo-0.3.2 → prezo-2026.1.2}/pyproject.toml +8 -2
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/app.py +8 -6
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/export.py +31 -6
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/images/ascii.py +7 -5
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/images/sixel.py +4 -4
- prezo-2026.1.2/src/prezo/layout.py +464 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/parser.py +11 -4
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/widgets/__init__.py +9 -1
- prezo-2026.1.2/src/prezo/widgets/slide_content.py +81 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/__init__.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/config.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/images/__init__.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/images/base.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/images/chafa.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/images/iterm.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/images/kitty.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/images/overlay.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/images/processor.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/screens/__init__.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/screens/base.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/screens/blackout.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/screens/goto.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/screens/help.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/screens/overview.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/screens/search.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/screens/toc.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/terminal.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/themes.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/widgets/image_display.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/widgets/slide_button.py +0 -0
- {prezo-0.3.2 → prezo-2026.1.2}/src/prezo/widgets/status_bar.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: prezo
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2026.1.2
|
|
4
4
|
Summary: A TUI-based presentation tool for the terminal, built with Textual.
|
|
5
5
|
Author: Stefane Fermigier
|
|
6
6
|
Author-email: Stefane Fermigier <sf@fermigier.com>
|
|
@@ -16,9 +16,10 @@ A TUI-based presentation tool for the terminal, built with [Textual](https://tex
|
|
|
16
16
|
|
|
17
17
|
Display presentations written in Markdown using conventions similar to those of [MARP](https://marp.app/) or [Deckset](https://www.deckset.com/).
|
|
18
18
|
|
|
19
|
-
## Features
|
|
19
|
+
## Features
|
|
20
20
|
|
|
21
21
|
- **Markdown presentations** - MARP/Deckset format with `---` slide separators
|
|
22
|
+
- **Column layouts** - Pandoc-style fenced divs for multi-column slides (`::: columns`)
|
|
22
23
|
- **Live reload** - Auto-refresh when file changes (1s polling)
|
|
23
24
|
- **Keyboard navigation** - Vim-style keys, arrow keys, and more
|
|
24
25
|
- **Slide overview** - Grid view for quick navigation (`o`)
|
|
@@ -130,11 +131,33 @@ Presenter notes go here (after ???)
|
|
|
130
131
|
|
|
131
132
|
# Third Slide
|
|
132
133
|
|
|
134
|
+
::: columns
|
|
135
|
+
::: column
|
|
136
|
+
**Left Column**
|
|
137
|
+
- Point A
|
|
138
|
+
- Point B
|
|
139
|
+
:::
|
|
140
|
+
|
|
141
|
+
::: column
|
|
142
|
+
**Right Column**
|
|
143
|
+
- Point C
|
|
144
|
+
- Point D
|
|
145
|
+
:::
|
|
146
|
+
:::
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
# Fourth Slide
|
|
151
|
+
|
|
133
152
|
<!-- notes: Alternative notes syntax -->
|
|
134
153
|
|
|
135
|
-
|
|
154
|
+
::: center
|
|
155
|
+
**Centered content**
|
|
156
|
+
:::
|
|
136
157
|
```
|
|
137
158
|
|
|
159
|
+
See the [Writing Presentations in Markdown](docs/tutorial.md) tutorial for a complete guide on creating presentations, including column layouts, images, presenter notes, and configuration directives.
|
|
160
|
+
|
|
138
161
|
## Themes
|
|
139
162
|
|
|
140
163
|
Available themes: `dark`, `light`, `dracula`, `solarized-dark`, `nord`, `gruvbox`
|
|
@@ -4,9 +4,10 @@ A TUI-based presentation tool for the terminal, built with [Textual](https://tex
|
|
|
4
4
|
|
|
5
5
|
Display presentations written in Markdown using conventions similar to those of [MARP](https://marp.app/) or [Deckset](https://www.deckset.com/).
|
|
6
6
|
|
|
7
|
-
## Features
|
|
7
|
+
## Features
|
|
8
8
|
|
|
9
9
|
- **Markdown presentations** - MARP/Deckset format with `---` slide separators
|
|
10
|
+
- **Column layouts** - Pandoc-style fenced divs for multi-column slides (`::: columns`)
|
|
10
11
|
- **Live reload** - Auto-refresh when file changes (1s polling)
|
|
11
12
|
- **Keyboard navigation** - Vim-style keys, arrow keys, and more
|
|
12
13
|
- **Slide overview** - Grid view for quick navigation (`o`)
|
|
@@ -118,11 +119,33 @@ Presenter notes go here (after ???)
|
|
|
118
119
|
|
|
119
120
|
# Third Slide
|
|
120
121
|
|
|
122
|
+
::: columns
|
|
123
|
+
::: column
|
|
124
|
+
**Left Column**
|
|
125
|
+
- Point A
|
|
126
|
+
- Point B
|
|
127
|
+
:::
|
|
128
|
+
|
|
129
|
+
::: column
|
|
130
|
+
**Right Column**
|
|
131
|
+
- Point C
|
|
132
|
+
- Point D
|
|
133
|
+
:::
|
|
134
|
+
:::
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
# Fourth Slide
|
|
139
|
+
|
|
121
140
|
<!-- notes: Alternative notes syntax -->
|
|
122
141
|
|
|
123
|
-
|
|
142
|
+
::: center
|
|
143
|
+
**Centered content**
|
|
144
|
+
:::
|
|
124
145
|
```
|
|
125
146
|
|
|
147
|
+
See the [Writing Presentations in Markdown](docs/tutorial.md) tutorial for a complete guide on creating presentations, including column layouts, images, presenter notes, and configuration directives.
|
|
148
|
+
|
|
126
149
|
## Themes
|
|
127
150
|
|
|
128
151
|
Available themes: `dark`, `light`, `dracula`, `solarized-dark`, `nord`, `gruvbox`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "prezo"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2026.1.2"
|
|
4
4
|
description = "A TUI-based presentation tool for the terminal, built with Textual."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -36,6 +36,12 @@ export = [
|
|
|
36
36
|
testpaths = ["tests"]
|
|
37
37
|
asyncio_mode = "auto"
|
|
38
38
|
asyncio_default_fixture_loop_scope = "function"
|
|
39
|
+
markers = [
|
|
40
|
+
"unit: Unit tests (fast, isolated, no I/O)",
|
|
41
|
+
"integration: Integration tests (component interactions, file I/O)",
|
|
42
|
+
"e2e: End-to-end tests (full app/CLI workflows)",
|
|
43
|
+
"slow: Slow tests that can be skipped with -m 'not slow'",
|
|
44
|
+
]
|
|
39
45
|
|
|
40
46
|
[build-system]
|
|
41
47
|
requires = ["uv_build>=0.8.4,<0.9.0"]
|
|
@@ -46,7 +52,7 @@ build-backend = "uv_build"
|
|
|
46
52
|
missing-source-for-stubs = false # markdown stubs bundled but source not found
|
|
47
53
|
missing-import = false # libsixel optional dependency
|
|
48
54
|
missing-attribute = false # PIL.Image.ADAPTIVE
|
|
49
|
-
no-matching-overload = false # PIL
|
|
55
|
+
no-matching-overload = false # PIL type stub issues
|
|
50
56
|
bad-argument-type = false # LiteralString strictness with string append
|
|
51
57
|
bad-override = false # Textual COMMANDS type override
|
|
52
58
|
|
|
@@ -39,7 +39,7 @@ from .screens import (
|
|
|
39
39
|
)
|
|
40
40
|
from .terminal import ImageCapability, detect_image_capability
|
|
41
41
|
from .themes import get_next_theme, get_theme
|
|
42
|
-
from .widgets import ImageDisplay, StatusBar
|
|
42
|
+
from .widgets import ImageDisplay, SlideContent, StatusBar
|
|
43
43
|
|
|
44
44
|
WELCOME_MESSAGE = """\
|
|
45
45
|
# Welcome to Prezo
|
|
@@ -242,12 +242,12 @@ class PrezoApp(App):
|
|
|
242
242
|
#slide-container {
|
|
243
243
|
width: 1fr;
|
|
244
244
|
height: 100%;
|
|
245
|
-
padding: 1 4;
|
|
245
|
+
padding: 0 4 1 4;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
#slide-content {
|
|
249
249
|
width: 100%;
|
|
250
|
-
padding:
|
|
250
|
+
padding: 0 2;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
/* Image container - hidden by default */
|
|
@@ -389,7 +389,7 @@ class PrezoApp(App):
|
|
|
389
389
|
yield ImageDisplay(id="slide-image")
|
|
390
390
|
# Text container
|
|
391
391
|
with VerticalScroll(id="slide-container"):
|
|
392
|
-
yield
|
|
392
|
+
yield SlideContent("", id="slide-content")
|
|
393
393
|
with Vertical(id="notes-panel"):
|
|
394
394
|
yield Static("Notes", id="notes-title")
|
|
395
395
|
yield Markdown("", id="notes-content")
|
|
@@ -519,7 +519,7 @@ class PrezoApp(App):
|
|
|
519
519
|
recent_section = _format_recent_files(self.state.recent_files)
|
|
520
520
|
if recent_section:
|
|
521
521
|
welcome += recent_section
|
|
522
|
-
self.query_one("#slide-content",
|
|
522
|
+
self.query_one("#slide-content", SlideContent).set_content(welcome)
|
|
523
523
|
status = self.query_one("#status-bar", StatusBar)
|
|
524
524
|
status.current = 0
|
|
525
525
|
status.total = 1
|
|
@@ -591,7 +591,9 @@ class PrezoApp(App):
|
|
|
591
591
|
image_widget.clear()
|
|
592
592
|
|
|
593
593
|
# Use cleaned content (bg images already removed by parser)
|
|
594
|
-
self.query_one("#slide-content",
|
|
594
|
+
self.query_one("#slide-content", SlideContent).set_content(
|
|
595
|
+
slide.content.strip()
|
|
596
|
+
)
|
|
595
597
|
|
|
596
598
|
container = self.query_one("#slide-container", VerticalScroll)
|
|
597
599
|
container.scroll_home(animate=False)
|
|
@@ -16,7 +16,8 @@ from rich.panel import Panel
|
|
|
16
16
|
from rich.style import Style
|
|
17
17
|
from rich.text import Text
|
|
18
18
|
|
|
19
|
-
from .
|
|
19
|
+
from .layout import has_layout_blocks, parse_layout, render_layout
|
|
20
|
+
from .parser import clean_marp_directives, extract_notes, parse_presentation
|
|
20
21
|
from .themes import get_theme
|
|
21
22
|
|
|
22
23
|
# Export result types
|
|
@@ -48,7 +49,7 @@ SVG_FORMAT_NO_CHROME = """\
|
|
|
48
49
|
}}
|
|
49
50
|
|
|
50
51
|
.{unique_id}-matrix {{
|
|
51
|
-
font-family: Fira Code, monospace;
|
|
52
|
+
font-family: Fira Code, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace;
|
|
52
53
|
font-size: {char_height}px;
|
|
53
54
|
line-height: {line_height}px;
|
|
54
55
|
font-variant-east-asian: full-width;
|
|
@@ -113,13 +114,17 @@ def render_slide_to_svg(
|
|
|
113
114
|
# Base style for the entire slide (background color)
|
|
114
115
|
base_style = Style(color=theme.text, bgcolor=theme.background)
|
|
115
116
|
|
|
116
|
-
# Render the
|
|
117
|
-
|
|
117
|
+
# Render the content (with layout support)
|
|
118
|
+
if has_layout_blocks(content):
|
|
119
|
+
blocks = parse_layout(content)
|
|
120
|
+
slide_content = render_layout(blocks)
|
|
121
|
+
else:
|
|
122
|
+
slide_content = Markdown(content)
|
|
118
123
|
|
|
119
124
|
# Create a panel with the slide content (height - 2 for status bar and padding)
|
|
120
125
|
panel_height = height - 2
|
|
121
126
|
panel = Panel(
|
|
122
|
-
|
|
127
|
+
slide_content,
|
|
123
128
|
title=f"[{theme.text_muted}]Slide {slide_num + 1}/{total_slides}[/]",
|
|
124
129
|
title_align="right",
|
|
125
130
|
border_style=Style(color=theme.primary),
|
|
@@ -149,6 +154,13 @@ def render_slide_to_svg(
|
|
|
149
154
|
else:
|
|
150
155
|
svg = console.export_svg(code_format=SVG_FORMAT_NO_CHROME)
|
|
151
156
|
|
|
157
|
+
# Add emoji font fallbacks to font-family declarations
|
|
158
|
+
# Rich only specifies "Fira Code, monospace" which lacks emoji glyphs
|
|
159
|
+
svg = svg.replace(
|
|
160
|
+
"font-family: Fira Code, monospace",
|
|
161
|
+
'font-family: Fira Code, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
|
|
162
|
+
)
|
|
163
|
+
|
|
152
164
|
# Add background color to SVG (Rich doesn't set it by default)
|
|
153
165
|
# Insert a rect element right after the opening svg tag
|
|
154
166
|
bg_rect = f'<rect width="100%" height="100%" fill="{theme.background}"/>'
|
|
@@ -386,6 +398,16 @@ HTML_TEMPLATE = """\
|
|
|
386
398
|
max-width: 100%;
|
|
387
399
|
height: auto;
|
|
388
400
|
}}
|
|
401
|
+
/* Multi-column layouts */
|
|
402
|
+
.columns {{
|
|
403
|
+
display: flex;
|
|
404
|
+
gap: 2rem;
|
|
405
|
+
align-items: flex-start;
|
|
406
|
+
}}
|
|
407
|
+
.columns > div {{
|
|
408
|
+
flex: 1;
|
|
409
|
+
min-width: 0;
|
|
410
|
+
}}
|
|
389
411
|
.notes {{
|
|
390
412
|
margin-top: 2rem;
|
|
391
413
|
padding: 1rem;
|
|
@@ -500,7 +522,10 @@ def export_to_html(
|
|
|
500
522
|
# Render each slide
|
|
501
523
|
slides_html = []
|
|
502
524
|
for i, slide in enumerate(presentation.slides):
|
|
503
|
-
|
|
525
|
+
# Use raw_content and clean with keep_divs=True to preserve column layouts
|
|
526
|
+
slide_content, _ = extract_notes(slide.raw_content)
|
|
527
|
+
cleaned_content = clean_marp_directives(slide_content, keep_divs=True)
|
|
528
|
+
content_html = render_slide_to_html(cleaned_content)
|
|
504
529
|
|
|
505
530
|
# Handle notes
|
|
506
531
|
notes_html = ""
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from functools import lru_cache
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import cast
|
|
7
8
|
|
|
8
9
|
# ASCII characters from dark to light
|
|
9
10
|
ASCII_CHARS = " .:-=+*#%@"
|
|
@@ -68,8 +69,8 @@ class AsciiRenderer:
|
|
|
68
69
|
# Resize
|
|
69
70
|
img = img.resize((new_width, new_height))
|
|
70
71
|
|
|
71
|
-
# Convert to ASCII
|
|
72
|
-
pixels = list(img.
|
|
72
|
+
# Convert to ASCII (grayscale values 0-255)
|
|
73
|
+
pixels = cast("list[int]", list(img.get_flattened_data()))
|
|
73
74
|
lines = []
|
|
74
75
|
|
|
75
76
|
for y in range(new_height):
|
|
@@ -127,8 +128,8 @@ class ColorAsciiRenderer(AsciiRenderer):
|
|
|
127
128
|
# Resize
|
|
128
129
|
img = img.resize((new_width, new_height))
|
|
129
130
|
|
|
130
|
-
# Convert to colored ASCII
|
|
131
|
-
pixels = list(img.
|
|
131
|
+
# Convert to colored ASCII (RGB tuples)
|
|
132
|
+
pixels = cast("list[tuple[int, int, int]]", list(img.get_flattened_data()))
|
|
132
133
|
lines = []
|
|
133
134
|
|
|
134
135
|
for y in range(new_height):
|
|
@@ -190,7 +191,8 @@ class HalfBlockRenderer:
|
|
|
190
191
|
new_height = new_height - (new_height % 2)
|
|
191
192
|
|
|
192
193
|
img = img.resize((new_width, new_height))
|
|
193
|
-
|
|
194
|
+
# RGB tuples for half-block rendering
|
|
195
|
+
pixels = cast("list[tuple[int, int, int]]", list(img.get_flattened_data()))
|
|
194
196
|
|
|
195
197
|
lines = []
|
|
196
198
|
for y in range(0, new_height, 2):
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import TYPE_CHECKING
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
6
|
|
|
7
7
|
if TYPE_CHECKING:
|
|
8
8
|
from pathlib import Path
|
|
@@ -108,12 +108,12 @@ class SixelRenderer:
|
|
|
108
108
|
|
|
109
109
|
img = img.resize((pixel_width, pixel_height))
|
|
110
110
|
|
|
111
|
-
# Get palette
|
|
111
|
+
# Get palette and pixel data (palette indices 0-255)
|
|
112
112
|
palette = img.getpalette()
|
|
113
|
-
pixels = list(img.
|
|
113
|
+
pixels = cast("list[int]", list(img.get_flattened_data()))
|
|
114
114
|
|
|
115
115
|
# Build sixel output
|
|
116
|
-
output = []
|
|
116
|
+
output: list[str] = []
|
|
117
117
|
|
|
118
118
|
# Start sixel sequence
|
|
119
119
|
output.append("\x1bPq")
|
|
@@ -0,0 +1,464 @@
|
|
|
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
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from io import StringIO
|
|
21
|
+
from typing import TYPE_CHECKING, Literal
|
|
22
|
+
|
|
23
|
+
from rich.console import Console, ConsoleOptions, Group, RenderResult
|
|
24
|
+
from rich.markdown import Markdown
|
|
25
|
+
from rich.measure import Measurement
|
|
26
|
+
from rich.text import Text
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from rich.console import RenderableType
|
|
30
|
+
|
|
31
|
+
# -----------------------------------------------------------------------------
|
|
32
|
+
# Data Types
|
|
33
|
+
# -----------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class LayoutBlock:
|
|
38
|
+
"""A block of content with layout information."""
|
|
39
|
+
|
|
40
|
+
type: Literal["plain", "columns", "column", "center"]
|
|
41
|
+
content: str = "" # Raw markdown content (for leaf blocks)
|
|
42
|
+
children: list[LayoutBlock] = field(default_factory=list)
|
|
43
|
+
width_percent: int = 0 # For column blocks (0 = auto/equal)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# -----------------------------------------------------------------------------
|
|
47
|
+
# Parser
|
|
48
|
+
# -----------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
# Pattern for opening fenced div: ::: type [width]
|
|
51
|
+
OPEN_PATTERN = re.compile(r"^:::\s*(\w+)(?:\s+(\d+))?\s*$")
|
|
52
|
+
# Pattern for closing fenced div: :::
|
|
53
|
+
CLOSE_PATTERN = re.compile(r"^:::\s*$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_layout(content: str) -> list[LayoutBlock]:
|
|
57
|
+
"""Parse markdown content into layout blocks.
|
|
58
|
+
|
|
59
|
+
Detects Pandoc-style fenced divs and builds a tree of LayoutBlocks.
|
|
60
|
+
Content outside fenced divs becomes plain blocks.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
content: Markdown content possibly containing fenced divs.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
List of LayoutBlock objects representing the content structure.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
lines = content.split("\n")
|
|
70
|
+
blocks: list[LayoutBlock] = []
|
|
71
|
+
i = 0
|
|
72
|
+
|
|
73
|
+
while i < len(lines):
|
|
74
|
+
line = lines[i]
|
|
75
|
+
match = OPEN_PATTERN.match(line)
|
|
76
|
+
|
|
77
|
+
if match:
|
|
78
|
+
block_type = match.group(1).lower()
|
|
79
|
+
width = int(match.group(2)) if match.group(2) else 0
|
|
80
|
+
|
|
81
|
+
# Find matching close and nested content
|
|
82
|
+
block, end_idx = _parse_fenced_block(lines, i, block_type, width)
|
|
83
|
+
if block:
|
|
84
|
+
blocks.append(block)
|
|
85
|
+
i = end_idx + 1
|
|
86
|
+
continue
|
|
87
|
+
# Unclosed block - treat as plain text, skip the opening line
|
|
88
|
+
i += 1
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# Not a fenced div - accumulate plain content
|
|
92
|
+
plain_lines = []
|
|
93
|
+
while i < len(lines):
|
|
94
|
+
if OPEN_PATTERN.match(lines[i]):
|
|
95
|
+
break
|
|
96
|
+
plain_lines.append(lines[i])
|
|
97
|
+
i += 1
|
|
98
|
+
|
|
99
|
+
if plain_lines:
|
|
100
|
+
plain_content = "\n".join(plain_lines).strip()
|
|
101
|
+
if plain_content:
|
|
102
|
+
blocks.append(LayoutBlock(type="plain", content=plain_content))
|
|
103
|
+
|
|
104
|
+
return blocks
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_fenced_block(
|
|
108
|
+
lines: list[str], start: int, block_type: str, width: int
|
|
109
|
+
) -> tuple[LayoutBlock | None, int]:
|
|
110
|
+
"""Parse a fenced div block starting at the given line.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
lines: All lines of content.
|
|
114
|
+
start: Starting line index (the opening :::).
|
|
115
|
+
block_type: The type from ::: type.
|
|
116
|
+
width: Width percentage if specified.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Tuple of (LayoutBlock or None, end line index).
|
|
120
|
+
|
|
121
|
+
"""
|
|
122
|
+
depth = 1
|
|
123
|
+
i = start + 1
|
|
124
|
+
content_lines: list[str] = []
|
|
125
|
+
|
|
126
|
+
while i < len(lines) and depth > 0:
|
|
127
|
+
line = lines[i]
|
|
128
|
+
|
|
129
|
+
if CLOSE_PATTERN.match(line):
|
|
130
|
+
depth -= 1
|
|
131
|
+
if depth == 0:
|
|
132
|
+
break
|
|
133
|
+
content_lines.append(line)
|
|
134
|
+
elif OPEN_PATTERN.match(line):
|
|
135
|
+
depth += 1
|
|
136
|
+
content_lines.append(line)
|
|
137
|
+
else:
|
|
138
|
+
content_lines.append(line)
|
|
139
|
+
i += 1
|
|
140
|
+
|
|
141
|
+
if depth != 0:
|
|
142
|
+
# Unclosed block - treat as plain text
|
|
143
|
+
return None, start
|
|
144
|
+
|
|
145
|
+
inner_content = "\n".join(content_lines)
|
|
146
|
+
|
|
147
|
+
# For columns/column types, parse children
|
|
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
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def has_layout_blocks(content: str) -> bool:
|
|
171
|
+
"""Check if content contains any layout directives.
|
|
172
|
+
|
|
173
|
+
Quick check to avoid parsing overhead for simple slides.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
content: Markdown content to check.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
True if content contains ::: directives.
|
|
180
|
+
|
|
181
|
+
"""
|
|
182
|
+
return ":::" in content
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# -----------------------------------------------------------------------------
|
|
186
|
+
# Renderer
|
|
187
|
+
# -----------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class ColumnsRenderable:
|
|
191
|
+
"""Rich renderable that displays columns side-by-side."""
|
|
192
|
+
|
|
193
|
+
def __init__(
|
|
194
|
+
self,
|
|
195
|
+
columns: list[LayoutBlock],
|
|
196
|
+
gap: int = 2,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Initialize columns renderable.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
columns: List of column LayoutBlocks.
|
|
202
|
+
gap: Number of spaces between columns.
|
|
203
|
+
|
|
204
|
+
"""
|
|
205
|
+
self.columns = columns
|
|
206
|
+
self.gap = gap
|
|
207
|
+
|
|
208
|
+
def __rich_console__(
|
|
209
|
+
self, console: Console, options: ConsoleOptions
|
|
210
|
+
) -> RenderResult:
|
|
211
|
+
"""Render columns side-by-side."""
|
|
212
|
+
if not self.columns:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
# Blank line before columns
|
|
216
|
+
yield Text("")
|
|
217
|
+
|
|
218
|
+
max_width = options.max_width
|
|
219
|
+
num_cols = len(self.columns)
|
|
220
|
+
|
|
221
|
+
# Calculate column widths
|
|
222
|
+
widths = self._calculate_widths(max_width, num_cols)
|
|
223
|
+
|
|
224
|
+
# Render each column to lines
|
|
225
|
+
column_outputs: list[list[str]] = []
|
|
226
|
+
for col, width in zip(self.columns, widths, strict=False):
|
|
227
|
+
lines = self._render_column(col, width, console)
|
|
228
|
+
column_outputs.append(lines)
|
|
229
|
+
|
|
230
|
+
# Merge columns side-by-side
|
|
231
|
+
merged = self._merge_columns(column_outputs, widths)
|
|
232
|
+
|
|
233
|
+
for line in merged:
|
|
234
|
+
yield Text.from_ansi(line)
|
|
235
|
+
|
|
236
|
+
# Blank line after columns
|
|
237
|
+
yield Text("")
|
|
238
|
+
|
|
239
|
+
def _calculate_widths(self, total_width: int, num_cols: int) -> list[int]:
|
|
240
|
+
"""Calculate width for each column.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
total_width: Total available width.
|
|
244
|
+
num_cols: Number of columns.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of widths for each column.
|
|
248
|
+
|
|
249
|
+
"""
|
|
250
|
+
# Account for gaps between columns
|
|
251
|
+
total_gap = self.gap * (num_cols - 1)
|
|
252
|
+
available = total_width - total_gap
|
|
253
|
+
|
|
254
|
+
# Check if any columns have explicit widths
|
|
255
|
+
explicit_widths = [c.width_percent for c in self.columns]
|
|
256
|
+
total_explicit = sum(w for w in explicit_widths if w > 0)
|
|
257
|
+
|
|
258
|
+
if total_explicit > 0:
|
|
259
|
+
# Use explicit percentages
|
|
260
|
+
widths = []
|
|
261
|
+
remaining = available
|
|
262
|
+
auto_count = sum(1 for w in explicit_widths if w == 0)
|
|
263
|
+
|
|
264
|
+
for w in explicit_widths:
|
|
265
|
+
if w > 0:
|
|
266
|
+
col_width = max(1, (available * w) // 100)
|
|
267
|
+
widths.append(col_width)
|
|
268
|
+
remaining -= col_width
|
|
269
|
+
else:
|
|
270
|
+
widths.append(0) # Placeholder
|
|
271
|
+
|
|
272
|
+
# Distribute remaining to auto columns
|
|
273
|
+
if auto_count > 0:
|
|
274
|
+
auto_width = remaining // auto_count
|
|
275
|
+
widths = [w if w > 0 else auto_width for w in widths]
|
|
276
|
+
else:
|
|
277
|
+
# Equal distribution
|
|
278
|
+
col_width = available // num_cols
|
|
279
|
+
widths = [col_width] * num_cols
|
|
280
|
+
|
|
281
|
+
return widths
|
|
282
|
+
|
|
283
|
+
def _render_column(
|
|
284
|
+
self, column: LayoutBlock, width: int, console: Console
|
|
285
|
+
) -> list[str]:
|
|
286
|
+
"""Render a single column to a list of lines.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
column: The column LayoutBlock.
|
|
290
|
+
width: Width in characters.
|
|
291
|
+
console: Rich console for rendering.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
List of rendered lines (with ANSI codes).
|
|
295
|
+
|
|
296
|
+
"""
|
|
297
|
+
# Create a console with fixed width for rendering
|
|
298
|
+
col_console = Console(
|
|
299
|
+
width=width,
|
|
300
|
+
force_terminal=True,
|
|
301
|
+
color_system=console.color_system,
|
|
302
|
+
record=True,
|
|
303
|
+
file=StringIO(),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Render markdown content
|
|
307
|
+
if column.content:
|
|
308
|
+
md = Markdown(column.content)
|
|
309
|
+
col_console.print(md)
|
|
310
|
+
|
|
311
|
+
# Get rendered lines
|
|
312
|
+
output = col_console.export_text(styles=True)
|
|
313
|
+
lines = output.split("\n")
|
|
314
|
+
|
|
315
|
+
# Ensure each line is padded to column width
|
|
316
|
+
# Note: This is tricky with ANSI codes. For now, we'll do basic padding.
|
|
317
|
+
padded = []
|
|
318
|
+
for line in lines:
|
|
319
|
+
# Strip trailing whitespace but preserve ANSI
|
|
320
|
+
stripped = line.rstrip()
|
|
321
|
+
padded.append(stripped)
|
|
322
|
+
|
|
323
|
+
return padded
|
|
324
|
+
|
|
325
|
+
def _merge_columns(
|
|
326
|
+
self, column_outputs: list[list[str]], widths: list[int]
|
|
327
|
+
) -> list[str]:
|
|
328
|
+
"""Merge column outputs side-by-side.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
column_outputs: List of line lists for each column.
|
|
332
|
+
widths: Width of each column.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Merged lines.
|
|
336
|
+
|
|
337
|
+
"""
|
|
338
|
+
if not column_outputs:
|
|
339
|
+
return []
|
|
340
|
+
|
|
341
|
+
# Find max height
|
|
342
|
+
max_height = max(len(col) for col in column_outputs)
|
|
343
|
+
|
|
344
|
+
# Pad shorter columns
|
|
345
|
+
for col in column_outputs:
|
|
346
|
+
while len(col) < max_height:
|
|
347
|
+
col.append("")
|
|
348
|
+
|
|
349
|
+
# Merge line by line
|
|
350
|
+
result = []
|
|
351
|
+
gap_str = " " * self.gap
|
|
352
|
+
|
|
353
|
+
for row_idx in range(max_height):
|
|
354
|
+
parts = []
|
|
355
|
+
for col_idx, col in enumerate(column_outputs):
|
|
356
|
+
line = col[row_idx] if row_idx < len(col) else ""
|
|
357
|
+
# Pad to column width (accounting for ANSI codes)
|
|
358
|
+
visible_len = _visible_length(line)
|
|
359
|
+
padding = widths[col_idx] - visible_len
|
|
360
|
+
if padding > 0:
|
|
361
|
+
line = line + " " * padding
|
|
362
|
+
parts.append(line)
|
|
363
|
+
|
|
364
|
+
result.append(gap_str.join(parts))
|
|
365
|
+
|
|
366
|
+
return result
|
|
367
|
+
|
|
368
|
+
def __rich_measure__(
|
|
369
|
+
self, console: Console, options: ConsoleOptions
|
|
370
|
+
) -> Measurement:
|
|
371
|
+
"""Return the measurement of this renderable."""
|
|
372
|
+
return Measurement(1, options.max_width)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class CenterRenderable:
|
|
376
|
+
"""Rich renderable that centers content horizontally."""
|
|
377
|
+
|
|
378
|
+
def __init__(self, content: str) -> None:
|
|
379
|
+
"""Initialize center renderable.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
content: Markdown content to center.
|
|
383
|
+
|
|
384
|
+
"""
|
|
385
|
+
self.content = content
|
|
386
|
+
|
|
387
|
+
def __rich_console__(
|
|
388
|
+
self, console: Console, options: ConsoleOptions
|
|
389
|
+
) -> RenderResult:
|
|
390
|
+
"""Render centered content."""
|
|
391
|
+
# Blank line before centered content
|
|
392
|
+
yield Text("")
|
|
393
|
+
|
|
394
|
+
# Use Markdown with center justification
|
|
395
|
+
md = Markdown(self.content, justify="center")
|
|
396
|
+
yield md
|
|
397
|
+
|
|
398
|
+
# Blank line after centered content
|
|
399
|
+
yield Text("")
|
|
400
|
+
|
|
401
|
+
def __rich_measure__(
|
|
402
|
+
self, console: Console, options: ConsoleOptions
|
|
403
|
+
) -> Measurement:
|
|
404
|
+
"""Return the measurement of this renderable."""
|
|
405
|
+
return Measurement(1, options.max_width)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def render_layout(
|
|
409
|
+
blocks: list[LayoutBlock],
|
|
410
|
+
) -> RenderableType:
|
|
411
|
+
"""Render layout blocks to a Rich renderable.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
blocks: List of LayoutBlocks from parse_layout().
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Rich renderable representing the layout.
|
|
418
|
+
|
|
419
|
+
"""
|
|
420
|
+
renderables: list[RenderableType] = []
|
|
421
|
+
|
|
422
|
+
for block in blocks:
|
|
423
|
+
match block.type:
|
|
424
|
+
case "plain":
|
|
425
|
+
renderables.append(Markdown(block.content))
|
|
426
|
+
case "columns":
|
|
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))
|
|
440
|
+
|
|
441
|
+
if len(renderables) == 1:
|
|
442
|
+
return renderables[0]
|
|
443
|
+
return Group(*renderables)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# -----------------------------------------------------------------------------
|
|
447
|
+
# Utilities
|
|
448
|
+
# -----------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
# ANSI escape sequence pattern
|
|
451
|
+
_ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _visible_length(text: str) -> int:
|
|
455
|
+
"""Calculate visible length of text, excluding ANSI codes.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
text: Text possibly containing ANSI escape codes.
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
Visible character count.
|
|
462
|
+
|
|
463
|
+
"""
|
|
464
|
+
return len(_ANSI_PATTERN.sub("", text))
|
|
@@ -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)
|
|
@@ -4,6 +4,14 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from .image_display import ImageDisplay
|
|
6
6
|
from .slide_button import SlideButton
|
|
7
|
+
from .slide_content import SlideContent
|
|
7
8
|
from .status_bar import ClockDisplay, ProgressBar, StatusBar
|
|
8
9
|
|
|
9
|
-
__all__ = [
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ClockDisplay",
|
|
12
|
+
"ImageDisplay",
|
|
13
|
+
"ProgressBar",
|
|
14
|
+
"SlideButton",
|
|
15
|
+
"SlideContent",
|
|
16
|
+
"StatusBar",
|
|
17
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Slide content widget with layout support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.markdown import Markdown as RichMarkdown
|
|
6
|
+
from textual.widgets import Static
|
|
7
|
+
|
|
8
|
+
from prezo.layout import has_layout_blocks, parse_layout, render_layout
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SlideContent(Static):
|
|
12
|
+
"""Widget that renders slide content with optional layout support.
|
|
13
|
+
|
|
14
|
+
Handles both plain markdown and Pandoc-style fenced div layouts:
|
|
15
|
+
- Plain markdown is rendered using Rich's Markdown
|
|
16
|
+
- Layout blocks (columns, center) are rendered using the layout module
|
|
17
|
+
|
|
18
|
+
Inherits from Static to properly handle Rich renderable display.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
DEFAULT_CSS = """
|
|
22
|
+
SlideContent {
|
|
23
|
+
width: 100%;
|
|
24
|
+
height: auto;
|
|
25
|
+
}
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
content: str = "",
|
|
31
|
+
*,
|
|
32
|
+
name: str | None = None,
|
|
33
|
+
id: str | None = None,
|
|
34
|
+
classes: str | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Initialize the slide content widget.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
content: Markdown content to display.
|
|
40
|
+
name: Widget name.
|
|
41
|
+
id: Widget ID.
|
|
42
|
+
classes: CSS classes.
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
# Initialize Static with the rendered content
|
|
46
|
+
super().__init__("", name=name, id=id, classes=classes)
|
|
47
|
+
self._raw_content = content
|
|
48
|
+
if content:
|
|
49
|
+
self._update_renderable()
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def raw_content(self) -> str:
|
|
53
|
+
"""Get the current raw markdown content."""
|
|
54
|
+
return self._raw_content
|
|
55
|
+
|
|
56
|
+
def set_content(self, content: str) -> None:
|
|
57
|
+
"""Set the markdown content and refresh the widget.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
content: New markdown content to display.
|
|
61
|
+
|
|
62
|
+
"""
|
|
63
|
+
self._raw_content = content
|
|
64
|
+
self._update_renderable()
|
|
65
|
+
|
|
66
|
+
def _update_renderable(self) -> None:
|
|
67
|
+
"""Update the internal renderable based on content."""
|
|
68
|
+
if not self._raw_content:
|
|
69
|
+
super().update("")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
# Check for layout directives
|
|
73
|
+
if has_layout_blocks(self._raw_content):
|
|
74
|
+
blocks = parse_layout(self._raw_content)
|
|
75
|
+
renderable = render_layout(blocks)
|
|
76
|
+
else:
|
|
77
|
+
# Plain markdown
|
|
78
|
+
renderable = RichMarkdown(self._raw_content)
|
|
79
|
+
|
|
80
|
+
# Use Static's update to set the renderable
|
|
81
|
+
super().update(renderable)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|