mdx-viewer 0.1.0__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.
- mdx_viewer-0.1.0/PKG-INFO +95 -0
- mdx_viewer-0.1.0/README.md +74 -0
- mdx_viewer-0.1.0/mdx/__init__.py +9 -0
- mdx_viewer-0.1.0/mdx/cli.py +172 -0
- mdx_viewer-0.1.0/mdx/parser.py +158 -0
- mdx_viewer-0.1.0/mdx/renderer.py +769 -0
- mdx_viewer-0.1.0/mdx_viewer.egg-info/PKG-INFO +95 -0
- mdx_viewer-0.1.0/mdx_viewer.egg-info/SOURCES.txt +12 -0
- mdx_viewer-0.1.0/mdx_viewer.egg-info/dependency_links.txt +1 -0
- mdx_viewer-0.1.0/mdx_viewer.egg-info/entry_points.txt +2 -0
- mdx_viewer-0.1.0/mdx_viewer.egg-info/requires.txt +1 -0
- mdx_viewer-0.1.0/mdx_viewer.egg-info/top_level.txt +1 -0
- mdx_viewer-0.1.0/pyproject.toml +43 -0
- mdx_viewer-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mdx-viewer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A beautiful Markdown viewer for terminal with code execution support
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mmx8lb/mdx
|
|
8
|
+
Project-URL: Repository, https://github.com/mmx8lb/mdx
|
|
9
|
+
Keywords: markdown,terminal,cli,viewer
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: click>=8.0.0
|
|
21
|
+
|
|
22
|
+
# MDX - Markdown Viewer for Terminal
|
|
23
|
+
|
|
24
|
+
<p align="center">
|
|
25
|
+
<img src="https://img.shields.io/pypi/v/mdx" alt="PyPI">
|
|
26
|
+
<img src="https://img.shields.io/pypi/l/mdx" alt="License">
|
|
27
|
+
<img src="https://img.shields.io/pypi/pyversions/mdx" alt="Python">
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
A beautiful Markdown viewer for terminal with code execution support.
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- 🎨 Beautiful syntax highlighting with multiple themes
|
|
35
|
+
- 📑 Table of contents navigation
|
|
36
|
+
- ⚡ Execute code blocks directly (bash, python)
|
|
37
|
+
- 🔍 Search within documents
|
|
38
|
+
- 📖 Multiple rendering modes
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install mdx
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# View a markdown file
|
|
50
|
+
mdx README.md
|
|
51
|
+
|
|
52
|
+
# Show table of contents
|
|
53
|
+
mdx README.md --toc
|
|
54
|
+
|
|
55
|
+
# Execute code blocks
|
|
56
|
+
mdx README.md --execute
|
|
57
|
+
|
|
58
|
+
# Jump to specific line
|
|
59
|
+
mdx README.md --line 100
|
|
60
|
+
|
|
61
|
+
# Use different theme
|
|
62
|
+
mdx README.md --theme dracula
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Options
|
|
66
|
+
|
|
67
|
+
| Option | Description |
|
|
68
|
+
|--------|-------------|
|
|
69
|
+
| `-e, --execute` | Execute code blocks |
|
|
70
|
+
| `-t, --toc` | Show table of contents |
|
|
71
|
+
| `-l, --line` | Jump to specific line |
|
|
72
|
+
| `-m, --theme` | Syntax highlighting theme |
|
|
73
|
+
|
|
74
|
+
## Themes
|
|
75
|
+
|
|
76
|
+
Available syntax highlighting themes:
|
|
77
|
+
- monokai (default)
|
|
78
|
+
- dracula
|
|
79
|
+
- github-dark
|
|
80
|
+
- nord
|
|
81
|
+
- solarized-dark
|
|
82
|
+
|
|
83
|
+
## Example
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Read documentation
|
|
87
|
+
mdx docs/linux-0.11-filesystem-analysis.md --toc
|
|
88
|
+
|
|
89
|
+
# Execute tutorial code blocks
|
|
90
|
+
mdx docs/tutorial.md --execute
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT License
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# MDX - Markdown Viewer for Terminal
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="https://img.shields.io/pypi/v/mdx" alt="PyPI">
|
|
5
|
+
<img src="https://img.shields.io/pypi/l/mdx" alt="License">
|
|
6
|
+
<img src="https://img.shields.io/pypi/pyversions/mdx" alt="Python">
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
A beautiful Markdown viewer for terminal with code execution support.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- 🎨 Beautiful syntax highlighting with multiple themes
|
|
14
|
+
- 📑 Table of contents navigation
|
|
15
|
+
- ⚡ Execute code blocks directly (bash, python)
|
|
16
|
+
- 🔍 Search within documents
|
|
17
|
+
- 📖 Multiple rendering modes
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install mdx
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# View a markdown file
|
|
29
|
+
mdx README.md
|
|
30
|
+
|
|
31
|
+
# Show table of contents
|
|
32
|
+
mdx README.md --toc
|
|
33
|
+
|
|
34
|
+
# Execute code blocks
|
|
35
|
+
mdx README.md --execute
|
|
36
|
+
|
|
37
|
+
# Jump to specific line
|
|
38
|
+
mdx README.md --line 100
|
|
39
|
+
|
|
40
|
+
# Use different theme
|
|
41
|
+
mdx README.md --theme dracula
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Options
|
|
45
|
+
|
|
46
|
+
| Option | Description |
|
|
47
|
+
|--------|-------------|
|
|
48
|
+
| `-e, --execute` | Execute code blocks |
|
|
49
|
+
| `-t, --toc` | Show table of contents |
|
|
50
|
+
| `-l, --line` | Jump to specific line |
|
|
51
|
+
| `-m, --theme` | Syntax highlighting theme |
|
|
52
|
+
|
|
53
|
+
## Themes
|
|
54
|
+
|
|
55
|
+
Available syntax highlighting themes:
|
|
56
|
+
- monokai (default)
|
|
57
|
+
- dracula
|
|
58
|
+
- github-dark
|
|
59
|
+
- nord
|
|
60
|
+
- solarized-dark
|
|
61
|
+
|
|
62
|
+
## Example
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Read documentation
|
|
66
|
+
mdx docs/linux-0.11-filesystem-analysis.md --toc
|
|
67
|
+
|
|
68
|
+
# Execute tutorial code blocks
|
|
69
|
+
mdx docs/tutorial.md --execute
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT License
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MDX - A beautiful Markdown viewer for terminal
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import curses
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from .parser import MarkdownParser
|
|
10
|
+
from .renderer import TerminalRenderer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MDXViewer:
|
|
14
|
+
"""Main MDX viewer class"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, filepath: str):
|
|
17
|
+
self.filepath = Path(filepath)
|
|
18
|
+
self.content = self.filepath.read_text()
|
|
19
|
+
self.parser = MarkdownParser(self.content)
|
|
20
|
+
self.parsed_content = self.parser.parse()
|
|
21
|
+
self.renderer = TerminalRenderer()
|
|
22
|
+
self.scroll = 0
|
|
23
|
+
|
|
24
|
+
def run(self):
|
|
25
|
+
"""Run the viewer"""
|
|
26
|
+
def run_curses(stdscr):
|
|
27
|
+
self._run_interactive(stdscr)
|
|
28
|
+
|
|
29
|
+
curses.wrapper(run_curses)
|
|
30
|
+
|
|
31
|
+
def _run_interactive(self, stdscr):
|
|
32
|
+
"""Run interactive mode with curses"""
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
stdscr.nodelay(True)
|
|
37
|
+
last_scroll = -1
|
|
38
|
+
last_screen_size = (0, 0)
|
|
39
|
+
|
|
40
|
+
# Initial render
|
|
41
|
+
self.renderer.render(stdscr, self.parsed_content, self.scroll)
|
|
42
|
+
last_scroll = self.scroll
|
|
43
|
+
|
|
44
|
+
while True:
|
|
45
|
+
try:
|
|
46
|
+
# Get key press
|
|
47
|
+
key = stdscr.getch()
|
|
48
|
+
|
|
49
|
+
# Handle keys (less-style)
|
|
50
|
+
if key == ord('q'):
|
|
51
|
+
break
|
|
52
|
+
elif key in [ord('j'), ord('J'), curses.KEY_DOWN]:
|
|
53
|
+
self.scroll_down()
|
|
54
|
+
# Force render on key press
|
|
55
|
+
self.renderer.render(stdscr, self.parsed_content, self.scroll)
|
|
56
|
+
last_scroll = self.scroll
|
|
57
|
+
elif key in [ord('k'), ord('K'), curses.KEY_UP]:
|
|
58
|
+
self.scroll_up()
|
|
59
|
+
# Force render on key press
|
|
60
|
+
self.renderer.render(stdscr, self.parsed_content, self.scroll)
|
|
61
|
+
last_scroll = self.scroll
|
|
62
|
+
elif key in [ord('g')]:
|
|
63
|
+
self.scroll_to_top()
|
|
64
|
+
# Force render on key press
|
|
65
|
+
self.renderer.render(stdscr, self.parsed_content, self.scroll)
|
|
66
|
+
last_scroll = self.scroll
|
|
67
|
+
elif key in [ord('G')]:
|
|
68
|
+
self.scroll_to_bottom()
|
|
69
|
+
# Force render on key press
|
|
70
|
+
self.renderer.render(stdscr, self.parsed_content, self.scroll)
|
|
71
|
+
last_scroll = self.scroll
|
|
72
|
+
elif key in [ord(' '), ord('f')]:
|
|
73
|
+
self.scroll_down(10)
|
|
74
|
+
# Force render on key press
|
|
75
|
+
self.renderer.render(stdscr, self.parsed_content, self.scroll)
|
|
76
|
+
last_scroll = self.scroll
|
|
77
|
+
elif key in [ord('b')]:
|
|
78
|
+
self.scroll_up(10)
|
|
79
|
+
# Force render on key press
|
|
80
|
+
self.renderer.render(stdscr, self.parsed_content, self.scroll)
|
|
81
|
+
last_scroll = self.scroll
|
|
82
|
+
elif key in [ord('/')]:
|
|
83
|
+
# Simple search (to be implemented)
|
|
84
|
+
pass
|
|
85
|
+
elif key in [ord('n')]:
|
|
86
|
+
# Next search result (to be implemented)
|
|
87
|
+
pass
|
|
88
|
+
elif key in [ord('N')]:
|
|
89
|
+
# Previous search result (to be implemented)
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
# Small delay to reduce CPU usage but maintain responsiveness
|
|
93
|
+
time.sleep(0.005)
|
|
94
|
+
|
|
95
|
+
except KeyboardInterrupt:
|
|
96
|
+
# Handle Ctrl+C gracefully
|
|
97
|
+
break
|
|
98
|
+
except Exception as e:
|
|
99
|
+
# Display error
|
|
100
|
+
try:
|
|
101
|
+
stdscr.clear()
|
|
102
|
+
stdscr.addstr(0, 0, f"Error: {str(e)}", curses.A_REVERSE | curses.A_BOLD)
|
|
103
|
+
stdscr.addstr(2, 0, "Press q to exit", curses.A_DIM)
|
|
104
|
+
stdscr.refresh()
|
|
105
|
+
time.sleep(2)
|
|
106
|
+
except:
|
|
107
|
+
pass
|
|
108
|
+
break
|
|
109
|
+
except KeyboardInterrupt:
|
|
110
|
+
# Handle Ctrl+C at the outer level
|
|
111
|
+
pass
|
|
112
|
+
except Exception as e:
|
|
113
|
+
# Display overall error
|
|
114
|
+
try:
|
|
115
|
+
stdscr.clear()
|
|
116
|
+
stdscr.addstr(0, 0, f"Fatal error: {str(e)}", curses.A_REVERSE | curses.A_BOLD)
|
|
117
|
+
stdscr.addstr(2, 0, "Press any key to exit", curses.A_DIM)
|
|
118
|
+
stdscr.refresh()
|
|
119
|
+
stdscr.getch() # Wait for user input
|
|
120
|
+
except:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
def scroll_down(self, n: int = 1):
|
|
124
|
+
"""Scroll down by n lines"""
|
|
125
|
+
# Get actual terminal height for dynamic calculation
|
|
126
|
+
import shutil
|
|
127
|
+
try:
|
|
128
|
+
height = shutil.get_terminal_size().lines
|
|
129
|
+
content_height = max(10, height - 5) # Reserve space for header and status
|
|
130
|
+
except:
|
|
131
|
+
content_height = 20 # Fallback to reasonable default
|
|
132
|
+
max_scroll = max(0, len(self.parsed_content) - content_height)
|
|
133
|
+
self.scroll = min(max_scroll, self.scroll + n)
|
|
134
|
+
|
|
135
|
+
def scroll_up(self, n: int = 1):
|
|
136
|
+
"""Scroll up by n lines"""
|
|
137
|
+
self.scroll = max(0, self.scroll - n)
|
|
138
|
+
|
|
139
|
+
def scroll_to_top(self):
|
|
140
|
+
"""Scroll to top"""
|
|
141
|
+
self.scroll = 0
|
|
142
|
+
|
|
143
|
+
def scroll_to_bottom(self):
|
|
144
|
+
"""Scroll to bottom"""
|
|
145
|
+
# Get actual terminal height for dynamic calculation
|
|
146
|
+
import shutil
|
|
147
|
+
try:
|
|
148
|
+
height = shutil.get_terminal_size().lines
|
|
149
|
+
content_height = max(10, height - 5) # Reserve space for header and status
|
|
150
|
+
except:
|
|
151
|
+
content_height = 20 # Fallback to reasonable default
|
|
152
|
+
self.scroll = max(0, len(self.parsed_content) - content_height)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main():
|
|
156
|
+
"""Main entry point"""
|
|
157
|
+
@click.command()
|
|
158
|
+
@click.argument('file', type=click.Path(exists=True))
|
|
159
|
+
@click.version_option(version='1.0.0', prog_name='mdx')
|
|
160
|
+
def cli(file):
|
|
161
|
+
"""MDX - Markdown Viewer for Terminal
|
|
162
|
+
|
|
163
|
+
View Markdown files in the terminal with beautiful formatting.
|
|
164
|
+
"""
|
|
165
|
+
viewer = MDXViewer(file)
|
|
166
|
+
viewer.run()
|
|
167
|
+
|
|
168
|
+
cli()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == '__main__':
|
|
172
|
+
main()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Markdown parser module"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import List, Tuple, Dict, Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MarkdownParser:
|
|
8
|
+
"""Markdown parser class"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, content: str):
|
|
11
|
+
self.content = content
|
|
12
|
+
self.lines = content.split('\n')
|
|
13
|
+
|
|
14
|
+
def parse(self) -> List[Tuple[str, Any]]:
|
|
15
|
+
"""Parse markdown content into structured data"""
|
|
16
|
+
result = []
|
|
17
|
+
in_code = False
|
|
18
|
+
code_lang = ""
|
|
19
|
+
code_lines = []
|
|
20
|
+
in_table = False
|
|
21
|
+
table_lines = []
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
for line in self.lines:
|
|
25
|
+
try:
|
|
26
|
+
stripped = line.strip()
|
|
27
|
+
|
|
28
|
+
# Code block handling
|
|
29
|
+
if stripped.startswith('```'):
|
|
30
|
+
if not in_code:
|
|
31
|
+
in_code = True
|
|
32
|
+
code_lang = stripped[3:].strip() or "text"
|
|
33
|
+
code_lines = []
|
|
34
|
+
else:
|
|
35
|
+
in_code = False
|
|
36
|
+
result.append(('code_block', (code_lang, code_lines)))
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
if in_code:
|
|
40
|
+
code_lines.append(line)
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
# Table handling
|
|
44
|
+
if stripped.startswith('|') and '|' in stripped:
|
|
45
|
+
if not in_table:
|
|
46
|
+
in_table = True
|
|
47
|
+
table_lines = []
|
|
48
|
+
table_lines.append(line)
|
|
49
|
+
continue
|
|
50
|
+
elif in_table:
|
|
51
|
+
in_table = False
|
|
52
|
+
result.append(('table', table_lines))
|
|
53
|
+
table_lines = []
|
|
54
|
+
|
|
55
|
+
# Header
|
|
56
|
+
if stripped.startswith('#'):
|
|
57
|
+
level = len(stripped) - len(stripped.lstrip('#'))
|
|
58
|
+
if level <= 6:
|
|
59
|
+
title = stripped.lstrip('#').strip()
|
|
60
|
+
result.append(('header', (level, title)))
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
# Horizontal rule
|
|
64
|
+
if stripped in ['---', '***', '___']:
|
|
65
|
+
result.append(('hr', None))
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# List item
|
|
69
|
+
if stripped.startswith(('- ', '* ', '+ ')):
|
|
70
|
+
content = stripped[2:]
|
|
71
|
+
result.append(('list_item', ('unordered', content)))
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# Numbered list
|
|
75
|
+
m = re.match(r'^\d+\.\s+(.*)', stripped)
|
|
76
|
+
if m:
|
|
77
|
+
result.append(('list_item', ('ordered', m.group(1))))
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
# Blockquote
|
|
81
|
+
if stripped.startswith('>'):
|
|
82
|
+
content = stripped[1:].strip()
|
|
83
|
+
result.append(('blockquote', content))
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
# Bold and italic
|
|
87
|
+
if '**' in line or '*' in line:
|
|
88
|
+
result.append(('text', line))
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# Regular text
|
|
92
|
+
result.append(('text', line))
|
|
93
|
+
except Exception as e:
|
|
94
|
+
# Handle line-level parsing errors
|
|
95
|
+
result.append(('text', line)) # Fallback to plain text
|
|
96
|
+
except Exception as e:
|
|
97
|
+
# Handle major parsing errors
|
|
98
|
+
result = [('text', f"Error parsing markdown: {str(e)}")]
|
|
99
|
+
|
|
100
|
+
# Handle any remaining table
|
|
101
|
+
if in_table and table_lines:
|
|
102
|
+
try:
|
|
103
|
+
result.append(('table', table_lines))
|
|
104
|
+
except:
|
|
105
|
+
# Fallback if table parsing fails
|
|
106
|
+
for line in table_lines:
|
|
107
|
+
result.append(('text', line))
|
|
108
|
+
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
def parse_table(self, table_lines: List[str]) -> Dict[str, Any]:
|
|
112
|
+
"""Parse table lines into structured data"""
|
|
113
|
+
if not table_lines:
|
|
114
|
+
return {}
|
|
115
|
+
|
|
116
|
+
# Parse header
|
|
117
|
+
header = table_lines[0].strip().split('|')
|
|
118
|
+
header = [h.strip() for h in header if h.strip()]
|
|
119
|
+
num_columns = len(header)
|
|
120
|
+
|
|
121
|
+
# Parse alignment
|
|
122
|
+
alignment = []
|
|
123
|
+
if len(table_lines) > 1:
|
|
124
|
+
align_line = table_lines[1].strip().split('|')
|
|
125
|
+
for i, cell in enumerate(align_line):
|
|
126
|
+
if i == 0 or i == len(align_line) - 1:
|
|
127
|
+
continue
|
|
128
|
+
cell = cell.strip()
|
|
129
|
+
if cell.startswith(':') and cell.endswith(':'):
|
|
130
|
+
alignment.append('center')
|
|
131
|
+
elif cell.startswith(':'):
|
|
132
|
+
alignment.append('left')
|
|
133
|
+
elif cell.endswith(':'):
|
|
134
|
+
alignment.append('right')
|
|
135
|
+
else:
|
|
136
|
+
alignment.append('left')
|
|
137
|
+
|
|
138
|
+
# Parse rows
|
|
139
|
+
rows = []
|
|
140
|
+
# Start from index 2 if there's an alignment line and data rows
|
|
141
|
+
# Skip processing if there are no data rows
|
|
142
|
+
if len(table_lines) > 2:
|
|
143
|
+
for line in table_lines[2:]:
|
|
144
|
+
cells = line.strip().split('|')
|
|
145
|
+
cells = [c.strip() for c in cells if c.strip()]
|
|
146
|
+
# Ensure each row has the same number of columns as header
|
|
147
|
+
while len(cells) < num_columns:
|
|
148
|
+
cells.append('')
|
|
149
|
+
if len(cells) > num_columns:
|
|
150
|
+
cells = cells[:num_columns]
|
|
151
|
+
if cells:
|
|
152
|
+
rows.append(cells)
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
'header': header,
|
|
156
|
+
'alignment': alignment,
|
|
157
|
+
'rows': rows
|
|
158
|
+
}
|