table-renderer 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.
- table_renderer-0.1.0/PKG-INFO +230 -0
- table_renderer-0.1.0/README.md +207 -0
- table_renderer-0.1.0/pyproject.toml +96 -0
- table_renderer-0.1.0/src/table_renderer/__init__.py +3 -0
- table_renderer-0.1.0/src/table_renderer/models/__init__.py +8 -0
- table_renderer-0.1.0/src/table_renderer/models/base.py +90 -0
- table_renderer-0.1.0/src/table_renderer/models/cell.py +72 -0
- table_renderer-0.1.0/src/table_renderer/models/column.py +31 -0
- table_renderer-0.1.0/src/table_renderer/models/row.py +15 -0
- table_renderer-0.1.0/src/table_renderer/models/style.py +107 -0
- table_renderer-0.1.0/src/table_renderer/models/table.py +162 -0
- table_renderer-0.1.0/src/table_renderer/py.typed +0 -0
- table_renderer-0.1.0/src/table_renderer/renderer.py +219 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: table-renderer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python library for rendering structured tables as high-quality images or HTML.
|
|
5
|
+
Keywords: table,renderer,rendering,image,html,weasyprint,pdfium,report
|
|
6
|
+
Author: pTaunium
|
|
7
|
+
Author-email: pTaunium <pTaunium@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Dist: jinja2~=3.0
|
|
18
|
+
Requires-Dist: pillow~=12.0
|
|
19
|
+
Requires-Dist: pypdfium2~=5.0
|
|
20
|
+
Requires-Dist: weasyprint>=68.1
|
|
21
|
+
Requires-Python: >=3.12, <3.15
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Table Renderer π
|
|
25
|
+
|
|
26
|
+
<!-- README-I18N:START -->
|
|
27
|
+
|
|
28
|
+
**English** | [ηΉι«δΈζ](README.zh-TW.md)
|
|
29
|
+
|
|
30
|
+
<!-- README-I18N:END -->
|
|
31
|
+
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
[](pyproject.toml)
|
|
34
|
+
[](https://github.com/pTaunium/table-renderer/actions)
|
|
35
|
+
|
|
36
|
+
`table-renderer` is a powerful and lightweight Python library designed to convert structured tables into high-quality images (PNG, JPG, WebP) or HTML. By combining the layout power of CSS with an intuitive Python API, it's perfect for automated reporting, Kubernetes CronJobs, or any data visualization task.
|
|
37
|
+
|
|
38
|
+
## β¨ Features
|
|
39
|
+
|
|
40
|
+
- **Broad Compatibility**: Fully supports **Python 3.12, 3.13, and 3.14**.
|
|
41
|
+
- **Business Friendly**: Uses `pypdfium2` (Apache-2.0) and `WeasyPrint` for a 100% MIT-compatible dependency chain.
|
|
42
|
+
- **High-Quality Rendering**: Powered by WeasyPrint (CSS engine), supporting complex text wrapping, alignment, and cell merging.
|
|
43
|
+
- **Auto-Crop**: Automatically crops images to the actual table area using visual detection, eliminating unnecessary white space.
|
|
44
|
+
- **Object-Oriented API**: Clean `Table` -> `Row/Column` -> `Cell` hierarchy with fluent interface support.
|
|
45
|
+
- **Cascading Styles**: Manage visual styles effortlessly with "Global Defaults + Local Overrides."
|
|
46
|
+
- **Multi-language Font Support**: Support for font stacks (e.g., different fonts for English and Chinese).
|
|
47
|
+
- **Customizable Resolution**: Adjustable DPI for high-resolution output (e.g., 300 DPI for print).
|
|
48
|
+
- **Lightweight Deployment**: No heavy browser dependencies (like Chromium), making it ideal for Docker/K8s.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## πΌ Visual Example
|
|
53
|
+
|
|
54
|
+
Here is an example table generated using the code in the [Quick Start](#-quick-start) section:
|
|
55
|
+
|
|
56
|
+

|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## π Quick Start
|
|
61
|
+
|
|
62
|
+
### Installation
|
|
63
|
+
|
|
64
|
+
If you are developing within this project, use `uv`:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv sync
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
To install it in another project:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install .
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Basic Usage
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from table_renderer import Table
|
|
80
|
+
|
|
81
|
+
# 1. Initialize a 3x3 table
|
|
82
|
+
table = Table(3, 3)
|
|
83
|
+
table.set_width(600)
|
|
84
|
+
table.set_border(1, "black", "solid")
|
|
85
|
+
|
|
86
|
+
# 2. Set header style (Row 0)
|
|
87
|
+
header = table.get_row(0)
|
|
88
|
+
header.set_background("#333").set_font(color="white", weight="bold", size=16)
|
|
89
|
+
header.set_align(horizontal="center")
|
|
90
|
+
|
|
91
|
+
table.cell(0, 0).set_text("Product")
|
|
92
|
+
table.cell(0, 1).set_text("Category")
|
|
93
|
+
table.cell(0, 2).set_text("Price")
|
|
94
|
+
|
|
95
|
+
# 3. Add content and demonstrate merging
|
|
96
|
+
table.cell(1, 0).set_text("MacBook Pro").span(rows=1, cols=2)
|
|
97
|
+
table.cell(1, 2).set_text("$2,000")
|
|
98
|
+
|
|
99
|
+
table.cell(2, 0).set_text("Magic Mouse")
|
|
100
|
+
table.cell(2, 1).set_text("Peripherals")
|
|
101
|
+
table.cell(2, 2).set_text("$79")
|
|
102
|
+
|
|
103
|
+
# 4. Global column styling (align prices to the right)
|
|
104
|
+
table.get_column(2).set_align(horizontal="right").set_width(100)
|
|
105
|
+
|
|
106
|
+
# 5. Export to image with custom DPI and padding
|
|
107
|
+
table.to_image("report.png", dpi=300, padding=20)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## π Advanced Features
|
|
113
|
+
|
|
114
|
+
### 1. Cell Merging
|
|
115
|
+
|
|
116
|
+
Use the anchor-based design to merge cells by specifying the starting cell and the span.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
# Merge first row, first two columns (1 row, 2 columns)
|
|
120
|
+
table.cell(0, 0).span(rows=1, cols=2).set_text("Merged Header")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### 2. Font Management
|
|
124
|
+
|
|
125
|
+
Load local font files and define font stacks for character-level fallback.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
# Load local font files (.ttf / .otf)
|
|
129
|
+
table.add_font("./fonts/Roboto-Regular.ttf")
|
|
130
|
+
table.add_font("./fonts/NotoSansTC-Medium.otf")
|
|
131
|
+
|
|
132
|
+
# Define a font stack: English prefers Roboto, Chinese falls back to Noto Sans
|
|
133
|
+
table.set_font(family="Roboto-Regular, NotoSansTC-Medium")
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 3. Embedding Images in Cells
|
|
137
|
+
|
|
138
|
+
You can easily embed local images into any cell.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
# Set an image with optional width/height constraints
|
|
142
|
+
table.cell(1, 0).set_image("path/to/logo.png", width=50)
|
|
143
|
+
table.cell(1, 0).set_text("Company Logo") # Text appears below the image
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### 4. Layout & Sizing
|
|
147
|
+
|
|
148
|
+
Mix fixed widths, percentages, and auto-sizing.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
table.set_width(800) # Total table width
|
|
152
|
+
table.get_column(0).set_width(200) # Fixed 200px
|
|
153
|
+
table.get_column(1).set_width("auto") # Distribute remaining space
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### 5. DPI, Formats & Padding
|
|
157
|
+
|
|
158
|
+
Fine-tune your output image quality and margins.
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
# High-res JPG with custom 20px padding
|
|
162
|
+
table.to_image("output.jpg", dpi=300, padding=20)
|
|
163
|
+
|
|
164
|
+
# Lightweight WebP with tight margins
|
|
165
|
+
table.to_image("output.webp", dpi=72, padding=0)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## π³ Docker / Kubernetes Deployment
|
|
171
|
+
|
|
172
|
+
Since `weasyprint` relies on system-level C libraries, your Dockerfile should include these dependencies:
|
|
173
|
+
|
|
174
|
+
```dockerfile
|
|
175
|
+
FROM python:3.12-slim
|
|
176
|
+
|
|
177
|
+
# Install system dependencies for WeasyPrint rendering and fonts
|
|
178
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
179
|
+
libpango-1.0-0 \
|
|
180
|
+
libharfbuzz0b \
|
|
181
|
+
libpangoft2-1.0-0 \
|
|
182
|
+
libpangocairo-1.0-0 \
|
|
183
|
+
fonts-noto-cjk \
|
|
184
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
185
|
+
|
|
186
|
+
WORKDIR /app
|
|
187
|
+
COPY . .
|
|
188
|
+
RUN pip install .
|
|
189
|
+
|
|
190
|
+
CMD ["python", "main.py"]
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## π§ͺ Development
|
|
196
|
+
|
|
197
|
+
### Running Tests
|
|
198
|
+
|
|
199
|
+
We use `pytest` for testing. Ensure you have installed the development dependencies.
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
uv run pytest --cov=src/table_renderer tests/
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Linting & Formatting
|
|
206
|
+
|
|
207
|
+
We use `ruff` to maintain code quality.
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
uv run ruff check .
|
|
211
|
+
uv run ruff format .
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## π€ Contributing
|
|
217
|
+
|
|
218
|
+
Contributions are welcome! If you find a bug or have a feature request, please open an issue or submit a pull request.
|
|
219
|
+
|
|
220
|
+
1. Fork the Project
|
|
221
|
+
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
|
222
|
+
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
|
223
|
+
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
|
224
|
+
5. Open a Pull Request
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## π License
|
|
229
|
+
|
|
230
|
+
Distributed under the MIT License. See [`LICENSE`](LICENSE) for more information.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Table Renderer π
|
|
2
|
+
|
|
3
|
+
<!-- README-I18N:START -->
|
|
4
|
+
|
|
5
|
+
**English** | [ηΉι«δΈζ](README.zh-TW.md)
|
|
6
|
+
|
|
7
|
+
<!-- README-I18N:END -->
|
|
8
|
+
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](pyproject.toml)
|
|
11
|
+
[](https://github.com/pTaunium/table-renderer/actions)
|
|
12
|
+
|
|
13
|
+
`table-renderer` is a powerful and lightweight Python library designed to convert structured tables into high-quality images (PNG, JPG, WebP) or HTML. By combining the layout power of CSS with an intuitive Python API, it's perfect for automated reporting, Kubernetes CronJobs, or any data visualization task.
|
|
14
|
+
|
|
15
|
+
## β¨ Features
|
|
16
|
+
|
|
17
|
+
- **Broad Compatibility**: Fully supports **Python 3.12, 3.13, and 3.14**.
|
|
18
|
+
- **Business Friendly**: Uses `pypdfium2` (Apache-2.0) and `WeasyPrint` for a 100% MIT-compatible dependency chain.
|
|
19
|
+
- **High-Quality Rendering**: Powered by WeasyPrint (CSS engine), supporting complex text wrapping, alignment, and cell merging.
|
|
20
|
+
- **Auto-Crop**: Automatically crops images to the actual table area using visual detection, eliminating unnecessary white space.
|
|
21
|
+
- **Object-Oriented API**: Clean `Table` -> `Row/Column` -> `Cell` hierarchy with fluent interface support.
|
|
22
|
+
- **Cascading Styles**: Manage visual styles effortlessly with "Global Defaults + Local Overrides."
|
|
23
|
+
- **Multi-language Font Support**: Support for font stacks (e.g., different fonts for English and Chinese).
|
|
24
|
+
- **Customizable Resolution**: Adjustable DPI for high-resolution output (e.g., 300 DPI for print).
|
|
25
|
+
- **Lightweight Deployment**: No heavy browser dependencies (like Chromium), making it ideal for Docker/K8s.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## πΌ Visual Example
|
|
30
|
+
|
|
31
|
+
Here is an example table generated using the code in the [Quick Start](#-quick-start) section:
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## π Quick Start
|
|
38
|
+
|
|
39
|
+
### Installation
|
|
40
|
+
|
|
41
|
+
If you are developing within this project, use `uv`:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv sync
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
To install it in another project:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install .
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Basic Usage
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from table_renderer import Table
|
|
57
|
+
|
|
58
|
+
# 1. Initialize a 3x3 table
|
|
59
|
+
table = Table(3, 3)
|
|
60
|
+
table.set_width(600)
|
|
61
|
+
table.set_border(1, "black", "solid")
|
|
62
|
+
|
|
63
|
+
# 2. Set header style (Row 0)
|
|
64
|
+
header = table.get_row(0)
|
|
65
|
+
header.set_background("#333").set_font(color="white", weight="bold", size=16)
|
|
66
|
+
header.set_align(horizontal="center")
|
|
67
|
+
|
|
68
|
+
table.cell(0, 0).set_text("Product")
|
|
69
|
+
table.cell(0, 1).set_text("Category")
|
|
70
|
+
table.cell(0, 2).set_text("Price")
|
|
71
|
+
|
|
72
|
+
# 3. Add content and demonstrate merging
|
|
73
|
+
table.cell(1, 0).set_text("MacBook Pro").span(rows=1, cols=2)
|
|
74
|
+
table.cell(1, 2).set_text("$2,000")
|
|
75
|
+
|
|
76
|
+
table.cell(2, 0).set_text("Magic Mouse")
|
|
77
|
+
table.cell(2, 1).set_text("Peripherals")
|
|
78
|
+
table.cell(2, 2).set_text("$79")
|
|
79
|
+
|
|
80
|
+
# 4. Global column styling (align prices to the right)
|
|
81
|
+
table.get_column(2).set_align(horizontal="right").set_width(100)
|
|
82
|
+
|
|
83
|
+
# 5. Export to image with custom DPI and padding
|
|
84
|
+
table.to_image("report.png", dpi=300, padding=20)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## π Advanced Features
|
|
90
|
+
|
|
91
|
+
### 1. Cell Merging
|
|
92
|
+
|
|
93
|
+
Use the anchor-based design to merge cells by specifying the starting cell and the span.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
# Merge first row, first two columns (1 row, 2 columns)
|
|
97
|
+
table.cell(0, 0).span(rows=1, cols=2).set_text("Merged Header")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 2. Font Management
|
|
101
|
+
|
|
102
|
+
Load local font files and define font stacks for character-level fallback.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
# Load local font files (.ttf / .otf)
|
|
106
|
+
table.add_font("./fonts/Roboto-Regular.ttf")
|
|
107
|
+
table.add_font("./fonts/NotoSansTC-Medium.otf")
|
|
108
|
+
|
|
109
|
+
# Define a font stack: English prefers Roboto, Chinese falls back to Noto Sans
|
|
110
|
+
table.set_font(family="Roboto-Regular, NotoSansTC-Medium")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### 3. Embedding Images in Cells
|
|
114
|
+
|
|
115
|
+
You can easily embed local images into any cell.
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# Set an image with optional width/height constraints
|
|
119
|
+
table.cell(1, 0).set_image("path/to/logo.png", width=50)
|
|
120
|
+
table.cell(1, 0).set_text("Company Logo") # Text appears below the image
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### 4. Layout & Sizing
|
|
124
|
+
|
|
125
|
+
Mix fixed widths, percentages, and auto-sizing.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
table.set_width(800) # Total table width
|
|
129
|
+
table.get_column(0).set_width(200) # Fixed 200px
|
|
130
|
+
table.get_column(1).set_width("auto") # Distribute remaining space
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 5. DPI, Formats & Padding
|
|
134
|
+
|
|
135
|
+
Fine-tune your output image quality and margins.
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
# High-res JPG with custom 20px padding
|
|
139
|
+
table.to_image("output.jpg", dpi=300, padding=20)
|
|
140
|
+
|
|
141
|
+
# Lightweight WebP with tight margins
|
|
142
|
+
table.to_image("output.webp", dpi=72, padding=0)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## π³ Docker / Kubernetes Deployment
|
|
148
|
+
|
|
149
|
+
Since `weasyprint` relies on system-level C libraries, your Dockerfile should include these dependencies:
|
|
150
|
+
|
|
151
|
+
```dockerfile
|
|
152
|
+
FROM python:3.12-slim
|
|
153
|
+
|
|
154
|
+
# Install system dependencies for WeasyPrint rendering and fonts
|
|
155
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
156
|
+
libpango-1.0-0 \
|
|
157
|
+
libharfbuzz0b \
|
|
158
|
+
libpangoft2-1.0-0 \
|
|
159
|
+
libpangocairo-1.0-0 \
|
|
160
|
+
fonts-noto-cjk \
|
|
161
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
162
|
+
|
|
163
|
+
WORKDIR /app
|
|
164
|
+
COPY . .
|
|
165
|
+
RUN pip install .
|
|
166
|
+
|
|
167
|
+
CMD ["python", "main.py"]
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## π§ͺ Development
|
|
173
|
+
|
|
174
|
+
### Running Tests
|
|
175
|
+
|
|
176
|
+
We use `pytest` for testing. Ensure you have installed the development dependencies.
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
uv run pytest --cov=src/table_renderer tests/
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Linting & Formatting
|
|
183
|
+
|
|
184
|
+
We use `ruff` to maintain code quality.
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
uv run ruff check .
|
|
188
|
+
uv run ruff format .
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## π€ Contributing
|
|
194
|
+
|
|
195
|
+
Contributions are welcome! If you find a bug or have a feature request, please open an issue or submit a pull request.
|
|
196
|
+
|
|
197
|
+
1. Fork the Project
|
|
198
|
+
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
|
199
|
+
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
|
200
|
+
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
|
201
|
+
5. Open a Pull Request
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## π License
|
|
206
|
+
|
|
207
|
+
Distributed under the MIT License. See [`LICENSE`](LICENSE) for more information.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.0,<0.12.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "table-renderer"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A Python library for rendering structured tables as high-quality images or HTML."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "pTaunium", email = "pTaunium@gmail.com" }
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.12,<3.15"
|
|
15
|
+
keywords = ["table", "renderer", "rendering", "image", "html", "weasyprint", "pdfium", "report"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Topic :: Multimedia :: Graphics",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"jinja2~=3.0",
|
|
28
|
+
"pillow~=12.0",
|
|
29
|
+
"pypdfium2~=5.0",
|
|
30
|
+
"weasyprint>=68.1",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"ipython",
|
|
37
|
+
"pytest~=9.0",
|
|
38
|
+
"pytest-cov~=7.0",
|
|
39
|
+
"ruff~=0.15",
|
|
40
|
+
"ty>=0.0.41",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
line-length = 88
|
|
45
|
+
indent-width = 4
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = [
|
|
49
|
+
"E", # pycodestyle error
|
|
50
|
+
"W", # pycodestyle warning
|
|
51
|
+
"I", # isort
|
|
52
|
+
"N", # pep8-naming
|
|
53
|
+
"F", # Pyflakes
|
|
54
|
+
"C90", # McCabe
|
|
55
|
+
"ANN", # flake8-annotations
|
|
56
|
+
"ASYNC", # flake8-async
|
|
57
|
+
"S", # flake8-bandit
|
|
58
|
+
"B", # flake8-bugbear
|
|
59
|
+
"A", # flake8-builtins
|
|
60
|
+
"C4", # flake8-comprehensions
|
|
61
|
+
"PIE", # flake8-pie
|
|
62
|
+
"PT", # flake8-pytest-style
|
|
63
|
+
"RET", # flake8-return
|
|
64
|
+
"SIM", # flake8-simplify
|
|
65
|
+
"PERF", # Perflint
|
|
66
|
+
"FURB", # refurb
|
|
67
|
+
"UP", # pyupgrade
|
|
68
|
+
"RUF", # Ruff-specific rules
|
|
69
|
+
]
|
|
70
|
+
ignore = [
|
|
71
|
+
"E501", # line-too-long
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint.isort]
|
|
75
|
+
section-order = [
|
|
76
|
+
"future",
|
|
77
|
+
"standard-library",
|
|
78
|
+
"third-party",
|
|
79
|
+
"first-party",
|
|
80
|
+
"local-folder",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
[tool.ruff.format]
|
|
84
|
+
docstring-code-format = true
|
|
85
|
+
docstring-code-line-length = "dynamic"
|
|
86
|
+
|
|
87
|
+
[tool.ruff.lint.per-file-ignores]
|
|
88
|
+
"tests/**/*.py" = [
|
|
89
|
+
"S101", # assert
|
|
90
|
+
"S105", # hardcoded-password-string
|
|
91
|
+
"S106", # hardcoded-password-func-arg
|
|
92
|
+
"S107", # hardcoded-password-default
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
[tool.coverage.run]
|
|
96
|
+
source = ["src"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from typing import Literal, Self
|
|
2
|
+
|
|
3
|
+
from .style import Style
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StyledObject:
|
|
7
|
+
"""Base class for objects that can be styled."""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
"""Initialize with a default Style."""
|
|
11
|
+
self.style = Style()
|
|
12
|
+
|
|
13
|
+
def set_font(
|
|
14
|
+
self,
|
|
15
|
+
size: int | None = None,
|
|
16
|
+
color: str | None = None,
|
|
17
|
+
family: str | None = None,
|
|
18
|
+
weight: Literal["normal", "bold"] | None = None,
|
|
19
|
+
) -> Self:
|
|
20
|
+
"""
|
|
21
|
+
Set font related styles.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
size: Font size in pixels.
|
|
25
|
+
color: Text color (e.g., 'red', '#ff0000').
|
|
26
|
+
family: Font family or font stack.
|
|
27
|
+
weight: 'normal' or 'bold'.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The object itself for chaining.
|
|
31
|
+
"""
|
|
32
|
+
self.style.font_size = size or self.style.font_size
|
|
33
|
+
self.style.font_color = color or self.style.font_color
|
|
34
|
+
self.style.font_family = family or self.style.font_family
|
|
35
|
+
self.style.font_weight = weight or self.style.font_weight
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def set_background(self, color: str) -> Self:
|
|
39
|
+
"""
|
|
40
|
+
Set the background color.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
color: Background color.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The object itself for chaining.
|
|
47
|
+
"""
|
|
48
|
+
self.style.bg_color = color
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def set_align(
|
|
52
|
+
self,
|
|
53
|
+
horizontal: Literal["left", "center", "right"] | None = None,
|
|
54
|
+
vertical: Literal["top", "middle", "bottom"] | None = None,
|
|
55
|
+
) -> Self:
|
|
56
|
+
"""
|
|
57
|
+
Set text alignment.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
horizontal: Horizontal alignment.
|
|
61
|
+
vertical: Vertical alignment.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The object itself for chaining.
|
|
65
|
+
"""
|
|
66
|
+
self.style.text_align = horizontal or self.style.text_align
|
|
67
|
+
self.style.vertical_align = vertical or self.style.vertical_align
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def set_border(
|
|
71
|
+
self,
|
|
72
|
+
width: int | None = None,
|
|
73
|
+
color: str | None = None,
|
|
74
|
+
style: Literal["solid", "dashed", "dotted"] | None = None,
|
|
75
|
+
) -> Self:
|
|
76
|
+
"""
|
|
77
|
+
Set border styles.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
width: Border width in pixels.
|
|
81
|
+
color: Border color.
|
|
82
|
+
style: Border style.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The object itself for chaining.
|
|
86
|
+
"""
|
|
87
|
+
self.style.border_width = width or self.style.border_width
|
|
88
|
+
self.style.border_color = color or self.style.border_color
|
|
89
|
+
self.style.border_style = style or self.style.border_style
|
|
90
|
+
return self
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from typing import Self
|
|
2
|
+
|
|
3
|
+
from .base import StyledObject
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Cell(StyledObject):
|
|
7
|
+
"""Represents a single cell in a table."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, value: str = "") -> None:
|
|
10
|
+
"""
|
|
11
|
+
Initialize cell with a value.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
value: The text content of the cell.
|
|
15
|
+
"""
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.value = value
|
|
18
|
+
self.row_span = 1
|
|
19
|
+
self.col_span = 1
|
|
20
|
+
self.is_merged = False # If True, this cell is covered by another merged cell
|
|
21
|
+
self.final_style: str = ""
|
|
22
|
+
self.image_path: str | None = None
|
|
23
|
+
self.image_url: str = ""
|
|
24
|
+
self.image_width: int | None = None
|
|
25
|
+
self.image_height: int | None = None
|
|
26
|
+
|
|
27
|
+
def set_text(self, text: str) -> Self:
|
|
28
|
+
"""
|
|
29
|
+
Update the text content of the cell.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
text: New text content.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The cell itself for chaining.
|
|
36
|
+
"""
|
|
37
|
+
self.value = text
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def set_image(
|
|
41
|
+
self, path: str, width: int | None = None, height: int | None = None
|
|
42
|
+
) -> Self:
|
|
43
|
+
"""
|
|
44
|
+
Set an image to be displayed in the cell.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
path: Path to the image file.
|
|
48
|
+
width: Optional display width in pixels.
|
|
49
|
+
height: Optional display height in pixels.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The cell itself for chaining.
|
|
53
|
+
"""
|
|
54
|
+
self.image_path = path
|
|
55
|
+
self.image_width = width
|
|
56
|
+
self.image_height = height
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def span(self, rows: int = 1, cols: int = 1) -> Self:
|
|
60
|
+
"""
|
|
61
|
+
Set the row and column span for merging.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
rows: Number of rows to span.
|
|
65
|
+
cols: Number of columns to span.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The cell itself for chaining.
|
|
69
|
+
"""
|
|
70
|
+
self.row_span = rows
|
|
71
|
+
self.col_span = cols
|
|
72
|
+
return self
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Self
|
|
2
|
+
|
|
3
|
+
from .base import StyledObject
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Column(StyledObject):
|
|
7
|
+
"""Represents a column in a table."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, index: int) -> None:
|
|
10
|
+
"""
|
|
11
|
+
Initialize a column.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
index: The 0-based index of the column.
|
|
15
|
+
"""
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.index = index
|
|
18
|
+
self.width: int | str = "auto"
|
|
19
|
+
|
|
20
|
+
def set_width(self, width: int | str) -> Self:
|
|
21
|
+
"""
|
|
22
|
+
Set the width of the column.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
width: Width in pixels (int) or CSS value (str, e.g., '10%').
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The column itself for chaining.
|
|
29
|
+
"""
|
|
30
|
+
self.width = width
|
|
31
|
+
return self
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .base import StyledObject
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Row(StyledObject):
|
|
5
|
+
"""Represents a row in a table."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, index: int) -> None:
|
|
8
|
+
"""
|
|
9
|
+
Initialize a row.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
index: The 0-based index of the row.
|
|
13
|
+
"""
|
|
14
|
+
super().__init__()
|
|
15
|
+
self.index = index
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Style:
|
|
7
|
+
"""Represents the visual style of a table element."""
|
|
8
|
+
|
|
9
|
+
font_size: int | None = None
|
|
10
|
+
font_color: str | None = None
|
|
11
|
+
font_family: str | None = None
|
|
12
|
+
font_weight: Literal["normal", "bold"] | None = None
|
|
13
|
+
bg_color: str | None = None
|
|
14
|
+
text_align: Literal["left", "center", "right"] | None = None
|
|
15
|
+
vertical_align: Literal["top", "middle", "bottom"] | None = None
|
|
16
|
+
border_width: int | None = None
|
|
17
|
+
border_color: str | None = None
|
|
18
|
+
border_style: Literal["solid", "dashed", "dotted"] | None = None
|
|
19
|
+
|
|
20
|
+
# Detailed borders
|
|
21
|
+
border_top_width: int | None = None
|
|
22
|
+
border_top_color: str | None = None
|
|
23
|
+
border_top_style: Literal["solid", "dashed", "dotted"] | None = None
|
|
24
|
+
|
|
25
|
+
border_bottom_width: int | None = None
|
|
26
|
+
border_bottom_color: str | None = None
|
|
27
|
+
border_bottom_style: Literal["solid", "dashed", "dotted"] | None = None
|
|
28
|
+
|
|
29
|
+
border_left_width: int | None = None
|
|
30
|
+
border_left_color: str | None = None
|
|
31
|
+
border_left_style: Literal["solid", "dashed", "dotted"] | None = None
|
|
32
|
+
|
|
33
|
+
border_right_width: int | None = None
|
|
34
|
+
border_right_color: str | None = None
|
|
35
|
+
border_right_style: Literal["solid", "dashed", "dotted"] | None = None
|
|
36
|
+
|
|
37
|
+
def merge(self, other: "Style") -> "Style":
|
|
38
|
+
"""
|
|
39
|
+
Merge another style into this one.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
other: The style to merge. Attributes in 'other' take precedence.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
A new Style instance containing the merged attributes.
|
|
46
|
+
"""
|
|
47
|
+
merged_style = Style(**self.__dict__)
|
|
48
|
+
for k, v in other.__dict__.items():
|
|
49
|
+
if v is not None:
|
|
50
|
+
setattr(merged_style, k, v)
|
|
51
|
+
return merged_style
|
|
52
|
+
|
|
53
|
+
def to_css(self) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Convert style attributes to a CSS string.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A string containing CSS property-value pairs.
|
|
59
|
+
"""
|
|
60
|
+
css = []
|
|
61
|
+
# Basic styles
|
|
62
|
+
if self.font_size:
|
|
63
|
+
css.append(f"font-size: {self.font_size}px;")
|
|
64
|
+
if self.font_color:
|
|
65
|
+
css.append(f"color: {self.font_color};")
|
|
66
|
+
if self.font_family:
|
|
67
|
+
# Handle font stacks: "Arial, Noto Sans" -> "'Arial', 'Noto Sans'"
|
|
68
|
+
families = [
|
|
69
|
+
f"'{f.strip()}'" if "'" not in f and '"' not in f else f.strip()
|
|
70
|
+
for f in self.font_family.split(",")
|
|
71
|
+
]
|
|
72
|
+
css.append(f"font-family: {', '.join(families)};")
|
|
73
|
+
if self.font_weight:
|
|
74
|
+
css.append(f"font-weight: {self.font_weight};")
|
|
75
|
+
if self.bg_color:
|
|
76
|
+
css.append(f"background-color: {self.bg_color};")
|
|
77
|
+
if self.text_align:
|
|
78
|
+
css.append(f"text-align: {self.text_align};")
|
|
79
|
+
if self.vertical_align:
|
|
80
|
+
css.append(f"vertical-align: {self.vertical_align};")
|
|
81
|
+
|
|
82
|
+
# Borders
|
|
83
|
+
css.extend(self._get_border_css())
|
|
84
|
+
return " ".join(css)
|
|
85
|
+
|
|
86
|
+
def _get_border_css(self) -> list[str]:
|
|
87
|
+
"""Internal helper to generate border CSS."""
|
|
88
|
+
css = []
|
|
89
|
+
if self.border_width:
|
|
90
|
+
css.append(f"border-width: {self.border_width}px;")
|
|
91
|
+
if self.border_color:
|
|
92
|
+
css.append(f"border-color: {self.border_color};")
|
|
93
|
+
if self.border_style:
|
|
94
|
+
css.append(f"border-style: {self.border_style};")
|
|
95
|
+
|
|
96
|
+
# Individual borders
|
|
97
|
+
for side in ["top", "bottom", "left", "right"]:
|
|
98
|
+
width = getattr(self, f"border_{side}_width")
|
|
99
|
+
color = getattr(self, f"border_{side}_color")
|
|
100
|
+
style = getattr(self, f"border_{side}_style")
|
|
101
|
+
if width:
|
|
102
|
+
css.append(f"border-{side}-width: {width}px;")
|
|
103
|
+
if color:
|
|
104
|
+
css.append(f"border-{side}-color: {color};")
|
|
105
|
+
if style:
|
|
106
|
+
css.append(f"border-{side}-style: {style};")
|
|
107
|
+
return css
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from typing import Self
|
|
2
|
+
|
|
3
|
+
from table_renderer.renderer import render_to_image, save_html
|
|
4
|
+
|
|
5
|
+
from .base import StyledObject
|
|
6
|
+
from .cell import Cell
|
|
7
|
+
from .column import Column
|
|
8
|
+
from .row import Row
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Table(StyledObject):
|
|
12
|
+
"""The main class representing a table to be rendered."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, rows: int = 0, cols: int = 0) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Initialize a table with initial dimensions.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
rows: Initial number of rows.
|
|
20
|
+
cols: Initial number of columns.
|
|
21
|
+
"""
|
|
22
|
+
super().__init__()
|
|
23
|
+
self._rows_count = rows
|
|
24
|
+
self._cols_count = cols
|
|
25
|
+
self._cells = [[Cell() for _ in range(cols)] for _ in range(rows)]
|
|
26
|
+
self._row_objects = [Row(i) for i in range(rows)]
|
|
27
|
+
self._col_objects = [Column(i) for i in range(cols)]
|
|
28
|
+
self.width: int | str = "100%"
|
|
29
|
+
self.font_files: list[str] = []
|
|
30
|
+
|
|
31
|
+
def cell(self, row_index: int, col_index: int) -> Cell:
|
|
32
|
+
"""
|
|
33
|
+
Get a cell at specific coordinates. Grows the table if needed.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
row_index: Row index.
|
|
37
|
+
col_index: Column index.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The Cell object.
|
|
41
|
+
"""
|
|
42
|
+
while row_index >= len(self._cells):
|
|
43
|
+
self._add_row()
|
|
44
|
+
while col_index >= len(self._cells[0]):
|
|
45
|
+
self._add_col()
|
|
46
|
+
return self._cells[row_index][col_index]
|
|
47
|
+
|
|
48
|
+
def get_row(self, row_index: int) -> Row:
|
|
49
|
+
"""
|
|
50
|
+
Get a Row object. Grows the table if needed.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
row_index: Row index.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The Row object.
|
|
57
|
+
"""
|
|
58
|
+
while row_index >= len(self._row_objects):
|
|
59
|
+
self._add_row()
|
|
60
|
+
return self._row_objects[row_index]
|
|
61
|
+
|
|
62
|
+
def get_column(self, col_index: int) -> Column:
|
|
63
|
+
"""
|
|
64
|
+
Get a Column object. Grows the table if needed.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
col_index: Column index.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The Column object.
|
|
71
|
+
"""
|
|
72
|
+
while col_index >= len(self._col_objects):
|
|
73
|
+
self._add_col()
|
|
74
|
+
return self._col_objects[col_index]
|
|
75
|
+
|
|
76
|
+
def _add_row(self) -> None:
|
|
77
|
+
"""Internal helper to add a row."""
|
|
78
|
+
new_row_idx = len(self._cells)
|
|
79
|
+
cols_count = len(self._cells[0]) if self._cells else self._cols_count
|
|
80
|
+
self._cells.append([Cell() for _ in range(cols_count)])
|
|
81
|
+
self._row_objects.append(Row(new_row_idx))
|
|
82
|
+
|
|
83
|
+
def _add_col(self) -> None:
|
|
84
|
+
"""Internal helper to add a column."""
|
|
85
|
+
for row in self._cells:
|
|
86
|
+
row.append(Cell())
|
|
87
|
+
self._col_objects.append(Column(len(self._col_objects)))
|
|
88
|
+
|
|
89
|
+
def set_width(self, width: int | str) -> Self:
|
|
90
|
+
"""
|
|
91
|
+
Set the total width of the table.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
width: Width in pixels or CSS value.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The table itself for chaining.
|
|
98
|
+
"""
|
|
99
|
+
self.width = width
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def add_font(self, font_path: str) -> Self:
|
|
103
|
+
"""
|
|
104
|
+
Register a local font file.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
font_path: Path to the .ttf or .otf file.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The table itself for chaining.
|
|
111
|
+
"""
|
|
112
|
+
self.font_files.append(font_path)
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def to_image(self, output_path: str, dpi: int = 144, padding: int = 10) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Render the table to an image file.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
output_path: Path to the output file (e.g., 'table.png', 'table.webp').
|
|
121
|
+
dpi: Resolution of the output image.
|
|
122
|
+
padding: Padding around the table in pixels.
|
|
123
|
+
"""
|
|
124
|
+
render_to_image(self, output_path, dpi=dpi, padding=padding)
|
|
125
|
+
|
|
126
|
+
def to_html(self, output_path: str) -> None:
|
|
127
|
+
"""
|
|
128
|
+
Export the table to an HTML file.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
output_path: Path to the output HTML file.
|
|
132
|
+
"""
|
|
133
|
+
save_html(self, output_path)
|
|
134
|
+
|
|
135
|
+
def _prepare_render(self) -> None:
|
|
136
|
+
"""Handle merging logic and cascading styles before rendering."""
|
|
137
|
+
# Reset is_merged flags
|
|
138
|
+
for row_index in range(len(self._cells)):
|
|
139
|
+
for col_index in range(len(self._cells[0])):
|
|
140
|
+
self._cells[row_index][col_index].is_merged = False
|
|
141
|
+
|
|
142
|
+
# Mark merged cells
|
|
143
|
+
for row_index in range(len(self._cells)):
|
|
144
|
+
for col_index in range(len(self._cells[0])):
|
|
145
|
+
self._mark_merged_area(row_index, col_index)
|
|
146
|
+
|
|
147
|
+
def _mark_merged_area(self, row_index: int, col_index: int) -> None:
|
|
148
|
+
"""Mark cells covered by a span as merged."""
|
|
149
|
+
cell = self._cells[row_index][col_index]
|
|
150
|
+
if cell.is_merged:
|
|
151
|
+
return
|
|
152
|
+
if cell.row_span > 1 or cell.col_span > 1:
|
|
153
|
+
for d_row in range(cell.row_span):
|
|
154
|
+
for d_col in range(cell.col_span):
|
|
155
|
+
if d_row == 0 and d_col == 0:
|
|
156
|
+
continue
|
|
157
|
+
if row_index + d_row < len(self._cells) and col_index + d_col < len(
|
|
158
|
+
self._cells[0]
|
|
159
|
+
):
|
|
160
|
+
self._cells[row_index + d_row][
|
|
161
|
+
col_index + d_col
|
|
162
|
+
].is_merged = True
|
|
File without changes
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from jinja2 import Template
|
|
5
|
+
from weasyprint import HTML
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .models.table import Table
|
|
9
|
+
|
|
10
|
+
HTML_TEMPLATE = """
|
|
11
|
+
<!DOCTYPE html>
|
|
12
|
+
<html>
|
|
13
|
+
<head>
|
|
14
|
+
<style>
|
|
15
|
+
@page {
|
|
16
|
+
margin: 0;
|
|
17
|
+
}
|
|
18
|
+
body {
|
|
19
|
+
margin: 0;
|
|
20
|
+
padding: 20px;
|
|
21
|
+
background-color: transparent;
|
|
22
|
+
}
|
|
23
|
+
{% for font in font_faces %}
|
|
24
|
+
@font-face {
|
|
25
|
+
font-family: '{{ font.name }}';
|
|
26
|
+
src: url('{{ font.path }}');
|
|
27
|
+
}
|
|
28
|
+
{% endfor %}
|
|
29
|
+
|
|
30
|
+
table {
|
|
31
|
+
border-collapse: collapse;
|
|
32
|
+
width: {{ table_width }};
|
|
33
|
+
{{ table_style }}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
td {
|
|
37
|
+
padding: 8px;
|
|
38
|
+
box-sizing: border-box;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
{% for col in columns %}
|
|
42
|
+
.col-{{ col.index }} {
|
|
43
|
+
width: {{ col.width }};
|
|
44
|
+
{{ col.style }}
|
|
45
|
+
}
|
|
46
|
+
{% endfor %}
|
|
47
|
+
|
|
48
|
+
{% for row in rows %}
|
|
49
|
+
.row-{{ row.index }} {
|
|
50
|
+
{{ row.style }}
|
|
51
|
+
}
|
|
52
|
+
{% endfor %}
|
|
53
|
+
</style>
|
|
54
|
+
</head>
|
|
55
|
+
<body>
|
|
56
|
+
<table>
|
|
57
|
+
{% for r_idx in range(cells|length) %}
|
|
58
|
+
<tr class="row-{{ r_idx }}">
|
|
59
|
+
{% for c_idx in range(cells[r_idx]|length) %}
|
|
60
|
+
{% set cell = cells[r_idx][c_idx] %}
|
|
61
|
+
{% if not cell.is_merged %}
|
|
62
|
+
<td class="col-{{ c_idx }}"
|
|
63
|
+
rowspan="{{ cell.row_span }}"
|
|
64
|
+
colspan="{{ cell.col_span }}"
|
|
65
|
+
style="{{ cell.final_style }}">
|
|
66
|
+
{% if cell.image_url %}
|
|
67
|
+
<img src="{{ cell.image_url }}"
|
|
68
|
+
style="{% if cell.image_width %}width: {{ cell.image_width }}px;{% endif %}
|
|
69
|
+
{% if cell.image_height %}height: {{ cell.image_height }}px;{% endif %}">
|
|
70
|
+
<br>
|
|
71
|
+
{% endif %}
|
|
72
|
+
{{ cell.value | replace('\\n', '<br>') }}
|
|
73
|
+
</td>
|
|
74
|
+
{% endif %}
|
|
75
|
+
{% endfor %}
|
|
76
|
+
</tr>
|
|
77
|
+
{% endfor %}
|
|
78
|
+
</table>
|
|
79
|
+
</body>
|
|
80
|
+
</html>
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render_to_html(table: "Table") -> str:
|
|
85
|
+
"""
|
|
86
|
+
Generate the HTML representation of the table.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
table: The Table object to render.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
A string containing the complete HTML document.
|
|
93
|
+
"""
|
|
94
|
+
table._prepare_render()
|
|
95
|
+
|
|
96
|
+
# Calculate final styles for each cell based on inheritance
|
|
97
|
+
font_faces = []
|
|
98
|
+
for font_path in table.font_files:
|
|
99
|
+
name = os.path.splitext(os.path.basename(font_path))[0]
|
|
100
|
+
# Convert path to absolute file URL for WeasyPrint
|
|
101
|
+
abs_path = os.path.abspath(font_path)
|
|
102
|
+
font_faces.append({"name": name, "path": f"file://{abs_path}"})
|
|
103
|
+
|
|
104
|
+
cells_data = []
|
|
105
|
+
for row_index in range(len(table._cells)):
|
|
106
|
+
row_data = []
|
|
107
|
+
for col_index in range(len(table._cells[0])):
|
|
108
|
+
cell = table._cells[row_index][col_index]
|
|
109
|
+
row = table.get_row(row_index)
|
|
110
|
+
col = table.get_column(col_index)
|
|
111
|
+
|
|
112
|
+
# Cascading: Table -> Row/Col -> Cell
|
|
113
|
+
base_style = table.style.merge(col.style).merge(row.style)
|
|
114
|
+
final_style = base_style.merge(cell.style)
|
|
115
|
+
|
|
116
|
+
# Handle Image URL resolution
|
|
117
|
+
cell.image_url = ""
|
|
118
|
+
if cell.image_path:
|
|
119
|
+
abs_img_path = os.path.abspath(cell.image_path)
|
|
120
|
+
cell.image_url = f"file://{abs_img_path}"
|
|
121
|
+
|
|
122
|
+
# Attach final style string for template
|
|
123
|
+
cell.final_style = final_style.to_css()
|
|
124
|
+
row_data.append(cell)
|
|
125
|
+
cells_data.append(row_data)
|
|
126
|
+
|
|
127
|
+
template = Template(HTML_TEMPLATE)
|
|
128
|
+
return template.render(
|
|
129
|
+
table_width=f"{table.width}px" if isinstance(table.width, int) else table.width,
|
|
130
|
+
table_style=table.style.to_css(),
|
|
131
|
+
font_faces=font_faces,
|
|
132
|
+
columns=[
|
|
133
|
+
{
|
|
134
|
+
"index": c.index,
|
|
135
|
+
"width": f"{c.width}px" if isinstance(c.width, int) else c.width,
|
|
136
|
+
"style": c.style.to_css(),
|
|
137
|
+
}
|
|
138
|
+
for c in table._col_objects
|
|
139
|
+
],
|
|
140
|
+
rows=[
|
|
141
|
+
{"index": r.index, "style": r.style.to_css()} for r in table._row_objects
|
|
142
|
+
],
|
|
143
|
+
cells=cells_data,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def render_to_image(
|
|
148
|
+
table: "Table",
|
|
149
|
+
output_path: str,
|
|
150
|
+
dpi: int = 144,
|
|
151
|
+
padding: int = 10,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Render the table to an image file.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
table: The Table object to render.
|
|
158
|
+
output_path: Destination path for the image.
|
|
159
|
+
dpi: Target resolution.
|
|
160
|
+
padding: Margin around the table.
|
|
161
|
+
"""
|
|
162
|
+
html_content = render_to_html(table)
|
|
163
|
+
# WeasyPrint v53+ removed write_png, so we render to PDF then convert to PNG
|
|
164
|
+
pdf_bytes = HTML(string=html_content).write_pdf()
|
|
165
|
+
|
|
166
|
+
import pypdfium2 as pdfium
|
|
167
|
+
from PIL import Image
|
|
168
|
+
|
|
169
|
+
# Load the PDF from bytes
|
|
170
|
+
with pdfium.PdfDocument(pdf_bytes) as pdf:
|
|
171
|
+
page = pdf[0]
|
|
172
|
+
|
|
173
|
+
# Render page to a bitmap
|
|
174
|
+
# scale_factor: 72 is the default PDF DPI
|
|
175
|
+
scale_factor = dpi / 72
|
|
176
|
+
bitmap = page.render(scale=scale_factor)
|
|
177
|
+
|
|
178
|
+
# Convert pdfium bitmap to Pillow Image
|
|
179
|
+
pil_image = bitmap.to_pil()
|
|
180
|
+
bitmap.close()
|
|
181
|
+
|
|
182
|
+
# Visual Auto-Crop: Find the bounding box of non-white pixels
|
|
183
|
+
# 1. Convert to RGB to ensure we have a standard background to check
|
|
184
|
+
bg = Image.new("RGB", pil_image.size, (255, 255, 255))
|
|
185
|
+
if pil_image.mode == "RGBA":
|
|
186
|
+
bg.paste(pil_image, mask=pil_image.split()[3])
|
|
187
|
+
else:
|
|
188
|
+
bg.paste(pil_image)
|
|
189
|
+
|
|
190
|
+
# 2. Invert and find bbox of "ink"
|
|
191
|
+
import PIL.ImageOps
|
|
192
|
+
|
|
193
|
+
inverted = PIL.ImageOps.invert(bg)
|
|
194
|
+
bbox = inverted.getbbox()
|
|
195
|
+
|
|
196
|
+
if bbox:
|
|
197
|
+
# Add some padding
|
|
198
|
+
left, top, right, bottom = bbox
|
|
199
|
+
left = max(0, left - padding)
|
|
200
|
+
top = max(0, top - padding)
|
|
201
|
+
right = min(pil_image.width, right + padding)
|
|
202
|
+
bottom = min(pil_image.height, bottom + padding)
|
|
203
|
+
pil_image = pil_image.crop((left, top, right, bottom))
|
|
204
|
+
|
|
205
|
+
# Save to the requested format (Pillow handles most formats)
|
|
206
|
+
pil_image.save(output_path)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def save_html(table: "Table", output_path: str) -> None:
|
|
210
|
+
"""
|
|
211
|
+
Save the table's HTML representation to a file.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
table: The Table object to export.
|
|
215
|
+
output_path: Path to the destination HTML file.
|
|
216
|
+
"""
|
|
217
|
+
html_content = render_to_html(table)
|
|
218
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
219
|
+
f.write(html_content)
|