table-renderer 0.4.0__tar.gz → 0.5.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.4.0 → table_renderer-0.5.0}/PKG-INFO +8 -5
- {table_renderer-0.4.0 → table_renderer-0.5.0}/README.md +6 -4
- {table_renderer-0.4.0 → table_renderer-0.5.0}/pyproject.toml +2 -1
- {table_renderer-0.4.0 → table_renderer-0.5.0}/src/table_renderer/__init__.py +1 -1
- table_renderer-0.5.0/src/table_renderer/backends/__init__.py +6 -0
- table_renderer-0.5.0/src/table_renderer/backends/base.py +44 -0
- table_renderer-0.5.0/src/table_renderer/backends/weasyprint_renderer.py +271 -0
- table_renderer-0.5.0/src/table_renderer/html_generator.py +126 -0
- {table_renderer-0.4.0 → table_renderer-0.5.0}/src/table_renderer/models/cell.py +27 -3
- table_renderer-0.5.0/src/table_renderer/models/row.py +31 -0
- {table_renderer-0.4.0 → table_renderer-0.5.0}/src/table_renderer/models/style.py +4 -4
- {table_renderer-0.4.0 → table_renderer-0.5.0}/src/table_renderer/models/table.py +31 -24
- table_renderer-0.5.0/src/table_renderer/renderer.py +122 -0
- table_renderer-0.5.0/src/table_renderer/templates/table.html.j2 +69 -0
- table_renderer-0.4.0/src/table_renderer/models/row.py +0 -15
- table_renderer-0.4.0/src/table_renderer/renderer.py +0 -267
- {table_renderer-0.4.0 → table_renderer-0.5.0}/src/table_renderer/models/__init__.py +0 -0
- {table_renderer-0.4.0 → table_renderer-0.5.0}/src/table_renderer/models/column.py +0 -0
- {table_renderer-0.4.0 → table_renderer-0.5.0}/src/table_renderer/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: table-renderer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A Python library for rendering structured tables as high-quality images or HTML.
|
|
5
5
|
Keywords: table,renderer,rendering,image,html,weasyprint,pdfium,report
|
|
6
6
|
Author: pTaunium
|
|
@@ -17,6 +17,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
17
17
|
Requires-Dist: jinja2~=3.0
|
|
18
18
|
Requires-Dist: pillow~=12.0
|
|
19
19
|
Requires-Dist: pypdfium2~=5.0
|
|
20
|
+
Requires-Dist: typing-extensions>=4.5.0
|
|
20
21
|
Requires-Dist: weasyprint>=68.1
|
|
21
22
|
Requires-Python: >=3.12, <3.15
|
|
22
23
|
Description-Content-Type: text/markdown
|
|
@@ -83,6 +84,7 @@ from table_renderer import Table
|
|
|
83
84
|
# 1. Initialize a 3x3 table
|
|
84
85
|
table = Table(3, 3)
|
|
85
86
|
table.set_border(width=1, color="black", style="solid")
|
|
87
|
+
table.set_width(400)
|
|
86
88
|
|
|
87
89
|
# 2. Set header style (Row 0)
|
|
88
90
|
header = table.get_row(0)
|
|
@@ -94,7 +96,7 @@ table.cell(0, 1).set_text("Category")
|
|
|
94
96
|
table.cell(0, 2).set_text("Price")
|
|
95
97
|
|
|
96
98
|
# 3. Add content and demonstrate merging
|
|
97
|
-
table.cell(1, 0).set_text("MacBook Pro").
|
|
99
|
+
table.cell(1, 0).set_text("MacBook Pro").set_span(rows=1, cols=2)
|
|
98
100
|
table.cell(1, 2).set_text("$2,000")
|
|
99
101
|
|
|
100
102
|
table.cell(2, 0).set_text("Magic Mouse")
|
|
@@ -118,7 +120,7 @@ Use the anchor-based design to merge cells by specifying the starting cell and t
|
|
|
118
120
|
|
|
119
121
|
```python
|
|
120
122
|
# Merge first row, first two columns (1 row, 2 columns)
|
|
121
|
-
table.cell(0, 0).
|
|
123
|
+
table.cell(0, 0).set_span(rows=1, cols=2).set_text("Merged Header")
|
|
122
124
|
```
|
|
123
125
|
|
|
124
126
|
### 2. Font Management
|
|
@@ -148,12 +150,13 @@ table.cell(1, 1).set_image("https://example.com/image.jpg", width=100)
|
|
|
148
150
|
|
|
149
151
|
### 4. Layout & Sizing
|
|
150
152
|
|
|
151
|
-
Mix fixed widths, percentages, and auto-sizing.
|
|
153
|
+
Mix fixed widths, heights, percentages, and auto-sizing.
|
|
152
154
|
|
|
153
155
|
```python
|
|
154
156
|
table.set_width(800) # Total table width
|
|
155
|
-
table.get_column(0).set_width(200) # Fixed 200px
|
|
157
|
+
table.get_column(0).set_width(200) # Fixed column width (200px)
|
|
156
158
|
table.get_column(1).set_width("auto") # Distribute remaining space
|
|
159
|
+
table.get_row(0).set_height(100) # Fixed row height (100px)
|
|
157
160
|
```
|
|
158
161
|
|
|
159
162
|
**Handling Extremely Wide Tables:**
|
|
@@ -60,6 +60,7 @@ from table_renderer import Table
|
|
|
60
60
|
# 1. Initialize a 3x3 table
|
|
61
61
|
table = Table(3, 3)
|
|
62
62
|
table.set_border(width=1, color="black", style="solid")
|
|
63
|
+
table.set_width(400)
|
|
63
64
|
|
|
64
65
|
# 2. Set header style (Row 0)
|
|
65
66
|
header = table.get_row(0)
|
|
@@ -71,7 +72,7 @@ table.cell(0, 1).set_text("Category")
|
|
|
71
72
|
table.cell(0, 2).set_text("Price")
|
|
72
73
|
|
|
73
74
|
# 3. Add content and demonstrate merging
|
|
74
|
-
table.cell(1, 0).set_text("MacBook Pro").
|
|
75
|
+
table.cell(1, 0).set_text("MacBook Pro").set_span(rows=1, cols=2)
|
|
75
76
|
table.cell(1, 2).set_text("$2,000")
|
|
76
77
|
|
|
77
78
|
table.cell(2, 0).set_text("Magic Mouse")
|
|
@@ -95,7 +96,7 @@ Use the anchor-based design to merge cells by specifying the starting cell and t
|
|
|
95
96
|
|
|
96
97
|
```python
|
|
97
98
|
# Merge first row, first two columns (1 row, 2 columns)
|
|
98
|
-
table.cell(0, 0).
|
|
99
|
+
table.cell(0, 0).set_span(rows=1, cols=2).set_text("Merged Header")
|
|
99
100
|
```
|
|
100
101
|
|
|
101
102
|
### 2. Font Management
|
|
@@ -125,12 +126,13 @@ table.cell(1, 1).set_image("https://example.com/image.jpg", width=100)
|
|
|
125
126
|
|
|
126
127
|
### 4. Layout & Sizing
|
|
127
128
|
|
|
128
|
-
Mix fixed widths, percentages, and auto-sizing.
|
|
129
|
+
Mix fixed widths, heights, percentages, and auto-sizing.
|
|
129
130
|
|
|
130
131
|
```python
|
|
131
132
|
table.set_width(800) # Total table width
|
|
132
|
-
table.get_column(0).set_width(200) # Fixed 200px
|
|
133
|
+
table.get_column(0).set_width(200) # Fixed column width (200px)
|
|
133
134
|
table.get_column(1).set_width("auto") # Distribute remaining space
|
|
135
|
+
table.get_row(0).set_height(100) # Fixed row height (100px)
|
|
134
136
|
```
|
|
135
137
|
|
|
136
138
|
**Handling Extremely Wide Tables:**
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "table-renderer"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "A Python library for rendering structured tables as high-quality images or HTML."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -27,6 +27,7 @@ dependencies = [
|
|
|
27
27
|
"jinja2~=3.0",
|
|
28
28
|
"pillow~=12.0",
|
|
29
29
|
"pypdfium2~=5.0",
|
|
30
|
+
"typing-extensions>=4.5.0",
|
|
30
31
|
"weasyprint>=68.1",
|
|
31
32
|
]
|
|
32
33
|
|
|
@@ -6,6 +6,6 @@ try:
|
|
|
6
6
|
__version__ = importlib.metadata.version("table-renderer")
|
|
7
7
|
except importlib.metadata.PackageNotFoundError:
|
|
8
8
|
# Package is not installed (e.g. during local development without pip install -e .)
|
|
9
|
-
__version__ = "0.
|
|
9
|
+
__version__ = "0.5.0"
|
|
10
10
|
|
|
11
11
|
__all__ = ["Cell", "Column", "Row", "Style", "StyledObject", "Table", "__version__"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Protocol defining the image rendering backend interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from PIL.Image import Image
|
|
9
|
+
|
|
10
|
+
from table_renderer.models.table import Table
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ImageRenderer(Protocol):
|
|
14
|
+
"""Interface for rendering a Table model to an image.
|
|
15
|
+
|
|
16
|
+
All rendering backends must implement this interface. This allows
|
|
17
|
+
swapping the underlying rendering engine (e.g., WeasyPrint, Blitz)
|
|
18
|
+
without changing the public API or HTML generation logic.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def render(
|
|
22
|
+
self,
|
|
23
|
+
table: Table,
|
|
24
|
+
*,
|
|
25
|
+
dpi: int = 144,
|
|
26
|
+
padding: int = 10,
|
|
27
|
+
background_color: str = "transparent",
|
|
28
|
+
) -> Image:
|
|
29
|
+
"""Render a Table to a tightly-cropped PIL Image.
|
|
30
|
+
|
|
31
|
+
The returned image should be cropped to content bounds
|
|
32
|
+
(plus the specified padding). It is the backend's
|
|
33
|
+
responsibility to ensure no excess whitespace.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
table: The Table data model to render.
|
|
37
|
+
dpi: Target resolution in dots per inch.
|
|
38
|
+
padding: Pixels of padding around content.
|
|
39
|
+
background_color: Background color.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
A PIL Image (RGBA mode) tightly cropped to the table.
|
|
43
|
+
"""
|
|
44
|
+
...
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""WeasyPrint-based rendering backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from weasyprint import HTML
|
|
8
|
+
|
|
9
|
+
from table_renderer.html_generator import render_to_html
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from PIL.Image import Image
|
|
13
|
+
|
|
14
|
+
from table_renderer.models.cell import Cell
|
|
15
|
+
from table_renderer.models.table import Table
|
|
16
|
+
|
|
17
|
+
_DEFAULT_COL_WIDTH = 100
|
|
18
|
+
_DEFAULT_FONT_SIZE = 16
|
|
19
|
+
_CELL_PADDING = 8
|
|
20
|
+
_LINE_HEIGHT_RATIO = 1.4
|
|
21
|
+
_MIN_PAGE_WIDTH = 200
|
|
22
|
+
_MIN_PAGE_HEIGHT = 200
|
|
23
|
+
_PAGE_PADDING = 60 # body padding (20px * 2) + safety margin
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WeasyPrintRenderer:
|
|
27
|
+
"""Renders tables via WeasyPrint (HTML → PDF → raster image).
|
|
28
|
+
|
|
29
|
+
This is the default rendering backend. It converts HTML to PDF
|
|
30
|
+
via WeasyPrint, rasterizes the PDF pages to bitmaps via pypdfium2,
|
|
31
|
+
and auto-crops excess whitespace from the PDF page.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def render(
|
|
35
|
+
self,
|
|
36
|
+
table: Table,
|
|
37
|
+
*,
|
|
38
|
+
dpi: int = 144,
|
|
39
|
+
padding: int = 10,
|
|
40
|
+
background_color: str = "transparent",
|
|
41
|
+
) -> Image:
|
|
42
|
+
"""Render a Table to a tightly-cropped PIL Image.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
table: The Table data model to render.
|
|
46
|
+
dpi: Target resolution in dots per inch.
|
|
47
|
+
padding: Pixels of padding around content.
|
|
48
|
+
background_color: Background color.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A PIL Image (RGBA mode) tightly cropped to the table.
|
|
52
|
+
"""
|
|
53
|
+
page_css = _estimate_page_css(table)
|
|
54
|
+
html = render_to_html(
|
|
55
|
+
table, background_color=background_color, extra_css=page_css
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# WeasyPrint v53+ removed write_png, so we render to PDF then convert
|
|
59
|
+
pdf_bytes = HTML(string=html).write_pdf()
|
|
60
|
+
full_page_image = _pdf_to_image(pdf_bytes, dpi=dpi)
|
|
61
|
+
return _auto_crop(full_page_image, padding=padding)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _estimate_page_css(table: Table) -> str:
|
|
65
|
+
"""Generate ``@page`` CSS rules with estimated canvas size.
|
|
66
|
+
|
|
67
|
+
Estimates the PDF page dimensions based on the actual table
|
|
68
|
+
content — column widths, font sizes, text line counts, and
|
|
69
|
+
embedded image heights — rather than fixed magic numbers.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
table: The Table object to estimate dimensions for.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A CSS string containing the ``@page`` rule.
|
|
76
|
+
"""
|
|
77
|
+
estimated_width = _estimate_width(table)
|
|
78
|
+
estimated_height = _estimate_height(table)
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
"@page {\n"
|
|
82
|
+
f" size: {estimated_width}px {estimated_height}px;"
|
|
83
|
+
" /* Dynamically estimated size */\n"
|
|
84
|
+
" margin: 0;\n"
|
|
85
|
+
"}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _estimate_width(table: Table) -> int:
|
|
90
|
+
"""Estimate the required page width in pixels.
|
|
91
|
+
|
|
92
|
+
Uses explicit column widths where available and falls back
|
|
93
|
+
to a default per-column width for unset columns.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
table: The Table to estimate width for.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Estimated width in pixels.
|
|
100
|
+
"""
|
|
101
|
+
if not table._cells:
|
|
102
|
+
return _MIN_PAGE_WIDTH
|
|
103
|
+
|
|
104
|
+
# If the table has an explicit pixel width, use it directly
|
|
105
|
+
if isinstance(table.width, int):
|
|
106
|
+
return max(_MIN_PAGE_WIDTH, table.width + _PAGE_PADDING)
|
|
107
|
+
|
|
108
|
+
# Sum column widths: use explicit value or default
|
|
109
|
+
total_width = 0
|
|
110
|
+
for col in table._col_objects:
|
|
111
|
+
if isinstance(col.width, int):
|
|
112
|
+
total_width += col.width
|
|
113
|
+
else:
|
|
114
|
+
total_width += _DEFAULT_COL_WIDTH
|
|
115
|
+
|
|
116
|
+
return max(_MIN_PAGE_WIDTH, total_width + _PAGE_PADDING)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _estimate_height(table: Table) -> int:
|
|
120
|
+
"""Estimate the required page height in pixels.
|
|
121
|
+
|
|
122
|
+
Calculates a per-row height based on the tallest cell in
|
|
123
|
+
each row, considering font size, number of text lines, and
|
|
124
|
+
embedded image height.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
table: The Table to estimate height for.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Estimated height in pixels.
|
|
131
|
+
"""
|
|
132
|
+
if not table._cells:
|
|
133
|
+
return _MIN_PAGE_HEIGHT
|
|
134
|
+
|
|
135
|
+
# Resolve the table-level default font size
|
|
136
|
+
table_font_size = table.style.font_size or _DEFAULT_FONT_SIZE
|
|
137
|
+
|
|
138
|
+
total_height = 0
|
|
139
|
+
for row_idx, row_cells in enumerate(table._cells):
|
|
140
|
+
row_obj = table._row_objects[row_idx]
|
|
141
|
+
|
|
142
|
+
# If an explicit integer height is provided, use it
|
|
143
|
+
if isinstance(row_obj.height, int):
|
|
144
|
+
total_height += row_obj.height
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
# Resolve row-level font size
|
|
148
|
+
row_font_size = row_obj.style.font_size or table_font_size
|
|
149
|
+
|
|
150
|
+
row_height = 0
|
|
151
|
+
for cell in row_cells:
|
|
152
|
+
if cell.is_merged:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
cell_height = _estimate_cell_height(cell, default_font_size=row_font_size)
|
|
156
|
+
row_height = max(row_height, cell_height)
|
|
157
|
+
|
|
158
|
+
total_height += row_height
|
|
159
|
+
|
|
160
|
+
return max(_MIN_PAGE_HEIGHT, total_height + _PAGE_PADDING)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _estimate_cell_height(cell: Cell, *, default_font_size: int) -> int:
|
|
164
|
+
"""Estimate the height of a single cell in pixels.
|
|
165
|
+
|
|
166
|
+
Accounts for font size, number of text lines (split by newlines),
|
|
167
|
+
cell padding, and embedded image height.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
cell: The Cell to estimate.
|
|
171
|
+
default_font_size: Inherited font size if the cell has none set.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Estimated cell height in pixels.
|
|
175
|
+
"""
|
|
176
|
+
font_size = cell.style.font_size or default_font_size
|
|
177
|
+
|
|
178
|
+
# Text height: number of lines * font_size * line-height + padding
|
|
179
|
+
line_count = max(1, cell.value.count("\n") + 1) if cell.value else 1
|
|
180
|
+
text_height = int(line_count * font_size * _LINE_HEIGHT_RATIO) + _CELL_PADDING * 2
|
|
181
|
+
|
|
182
|
+
# Image height (if present)
|
|
183
|
+
image_height = (cell.image_height or 0) + (_CELL_PADDING if cell.image_path else 0)
|
|
184
|
+
|
|
185
|
+
return text_height + image_height
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _pdf_to_image(pdf_bytes: bytes, *, dpi: int) -> Image:
|
|
189
|
+
"""Convert PDF bytes to a single stitched PIL Image.
|
|
190
|
+
|
|
191
|
+
Renders each page of the PDF at the specified DPI and
|
|
192
|
+
stitches them vertically into one tall image.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
pdf_bytes: Raw PDF bytes.
|
|
196
|
+
dpi: Target resolution in dots per inch.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
A PIL RGBA Image with all pages stitched vertically.
|
|
200
|
+
"""
|
|
201
|
+
import pypdfium2 as pdfium
|
|
202
|
+
from PIL import Image
|
|
203
|
+
|
|
204
|
+
with pdfium.PdfDocument(pdf_bytes) as pdf:
|
|
205
|
+
rendered_pages = []
|
|
206
|
+
scale_factor = dpi / 72 # PDF standard resolution is 72 DPI
|
|
207
|
+
|
|
208
|
+
# Render each page to a PIL Image
|
|
209
|
+
for page in pdf:
|
|
210
|
+
# fill_color=(255, 255, 255, 0) ensures transparent background
|
|
211
|
+
bitmap = page.render(scale=scale_factor, fill_color=(255, 255, 255, 0))
|
|
212
|
+
page_image = bitmap.to_pil()
|
|
213
|
+
rendered_pages.append(page_image)
|
|
214
|
+
bitmap.close()
|
|
215
|
+
|
|
216
|
+
if not rendered_pages:
|
|
217
|
+
return Image.new("RGBA", (1, 1), (255, 255, 255, 0))
|
|
218
|
+
|
|
219
|
+
# Stitch all pages vertically into one long image
|
|
220
|
+
total_width = max(img.width for img in rendered_pages)
|
|
221
|
+
total_height = sum(img.height for img in rendered_pages)
|
|
222
|
+
stitched = Image.new("RGBA", (total_width, total_height), (255, 255, 255, 0))
|
|
223
|
+
|
|
224
|
+
y_offset = 0
|
|
225
|
+
for img in rendered_pages:
|
|
226
|
+
stitched.paste(img, (0, y_offset))
|
|
227
|
+
y_offset += img.height
|
|
228
|
+
|
|
229
|
+
return stitched
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _auto_crop(image: Image, *, padding: int) -> Image:
|
|
233
|
+
"""Crop excess PDF page whitespace around the table.
|
|
234
|
+
|
|
235
|
+
WeasyPrint renders to a full PDF page, so the rendered image
|
|
236
|
+
typically has large margins that need to be trimmed.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
image: The source PIL Image.
|
|
240
|
+
padding: Pixels of padding to preserve around content.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
The cropped image.
|
|
244
|
+
"""
|
|
245
|
+
import PIL.ImageOps
|
|
246
|
+
from PIL import Image
|
|
247
|
+
|
|
248
|
+
# Find the bounding box of non-transparent/non-white content.
|
|
249
|
+
# Use 'L' (grayscale) mode to reduce memory usage (1 byte per pixel vs 3).
|
|
250
|
+
if image.mode == "RGBA":
|
|
251
|
+
# Flatten RGBA onto a white grayscale background
|
|
252
|
+
grayscale = Image.new("L", image.size, 255)
|
|
253
|
+
grayscale.paste(image.convert("L"), mask=image.getchannel("A"))
|
|
254
|
+
else:
|
|
255
|
+
# For RGB or other modes, just convert to grayscale
|
|
256
|
+
grayscale = image.convert("L")
|
|
257
|
+
|
|
258
|
+
# Invert and find bbox of "ink" (non-white pixels)
|
|
259
|
+
inverted = PIL.ImageOps.invert(grayscale)
|
|
260
|
+
bbox = inverted.getbbox()
|
|
261
|
+
|
|
262
|
+
if bbox:
|
|
263
|
+
# Expand crop region by the requested padding, clamped to image bounds
|
|
264
|
+
left, top, right, bottom = bbox
|
|
265
|
+
left = max(0, left - padding)
|
|
266
|
+
top = max(0, top - padding)
|
|
267
|
+
right = min(image.width, right + padding)
|
|
268
|
+
bottom = min(image.height, bottom + padding)
|
|
269
|
+
image = image.crop((left, top, right, bottom))
|
|
270
|
+
|
|
271
|
+
return image
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""HTML generation from Table models.
|
|
2
|
+
|
|
3
|
+
This module converts Table data models into HTML document strings.
|
|
4
|
+
It provides shared utilities that rendering backends can use, but
|
|
5
|
+
is not tied to any specific rendering engine.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from jinja2 import Environment, FileSystemLoader, Template
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .models.table import Table
|
|
18
|
+
|
|
19
|
+
# HTML template loaded from templates/table.html.j2
|
|
20
|
+
_JINJA_ENV: Environment | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_template() -> Template:
|
|
24
|
+
"""Return the cached Jinja2 table template, initializing the environment on first call."""
|
|
25
|
+
global _JINJA_ENV
|
|
26
|
+
if _JINJA_ENV is None:
|
|
27
|
+
template_dir = Path(__file__).parent / "templates"
|
|
28
|
+
_JINJA_ENV = Environment(loader=FileSystemLoader(template_dir)) # noqa: S701
|
|
29
|
+
return _JINJA_ENV.get_template("table.html.j2")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _format_length(value: int | str) -> str:
|
|
33
|
+
"""Format an integer as pixels, or return a string CSS value as is."""
|
|
34
|
+
return f"{value}px" if isinstance(value, int) else value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _prepare_render_context(
|
|
38
|
+
table: Table, *, background_color: str = "transparent"
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
"""Prepare the Jinja2 template context from a Table model.
|
|
41
|
+
|
|
42
|
+
Handles merge-flag setup, font-face resolution, image URL
|
|
43
|
+
resolution, and extraction of column/row/cell data.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
table: The Table object to prepare.
|
|
47
|
+
background_color: The background color of the body.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A dict suitable for passing to ``Template.render()``.
|
|
51
|
+
"""
|
|
52
|
+
table._prepare_render()
|
|
53
|
+
|
|
54
|
+
font_faces = []
|
|
55
|
+
for font_path in table.font_files:
|
|
56
|
+
font_name = os.path.splitext(os.path.basename(font_path))[0]
|
|
57
|
+
# Convert path to absolute file URL for rendering engines
|
|
58
|
+
absolute_font_path = os.path.abspath(font_path)
|
|
59
|
+
font_faces.append({"name": font_name, "path": f"file://{absolute_font_path}"})
|
|
60
|
+
|
|
61
|
+
cell_matrix = []
|
|
62
|
+
for row_index in range(len(table._cells)):
|
|
63
|
+
cell_row = []
|
|
64
|
+
for col_index in range(len(table._cells[0])):
|
|
65
|
+
cell = table._cells[row_index][col_index]
|
|
66
|
+
|
|
67
|
+
# Handle Image URL resolution
|
|
68
|
+
cell.image_url = ""
|
|
69
|
+
if cell.image_path:
|
|
70
|
+
if cell.image_path.startswith(("http://", "https://")):
|
|
71
|
+
cell.image_url = cell.image_path
|
|
72
|
+
else:
|
|
73
|
+
absolute_image_path = os.path.abspath(cell.image_path)
|
|
74
|
+
cell.image_url = f"file://{absolute_image_path}"
|
|
75
|
+
|
|
76
|
+
cell_row.append(cell)
|
|
77
|
+
cell_matrix.append(cell_row)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"background_color": background_color,
|
|
81
|
+
"table_width": _format_length(table.width),
|
|
82
|
+
"table_style": table.style.to_css(),
|
|
83
|
+
"font_faces": font_faces,
|
|
84
|
+
"columns": [
|
|
85
|
+
{
|
|
86
|
+
"index": c.index,
|
|
87
|
+
"width": _format_length(c.width),
|
|
88
|
+
"style": c.style.to_css(),
|
|
89
|
+
}
|
|
90
|
+
for c in table._col_objects
|
|
91
|
+
],
|
|
92
|
+
"rows": [
|
|
93
|
+
{
|
|
94
|
+
"index": r.index,
|
|
95
|
+
"height": _format_length(r.height),
|
|
96
|
+
"style": r.style.to_css(),
|
|
97
|
+
}
|
|
98
|
+
for r in table._row_objects
|
|
99
|
+
],
|
|
100
|
+
"cells": cell_matrix,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def render_to_html(
|
|
105
|
+
table: Table,
|
|
106
|
+
*,
|
|
107
|
+
background_color: str = "transparent",
|
|
108
|
+
extra_css: str = "",
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Generate the HTML representation of the table.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
table: The Table object to render.
|
|
114
|
+
background_color: The background color of the body.
|
|
115
|
+
extra_css: Additional CSS to inject into the document head.
|
|
116
|
+
Used by backends to add engine-specific rules
|
|
117
|
+
(e.g., ``@page`` rules for WeasyPrint).
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
A string containing the complete HTML document.
|
|
121
|
+
"""
|
|
122
|
+
context = _prepare_render_context(table, background_color=background_color)
|
|
123
|
+
context["extra_css"] = extra_css
|
|
124
|
+
template = _get_template()
|
|
125
|
+
|
|
126
|
+
return template.render(**context)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from typing import Self
|
|
2
2
|
|
|
3
|
+
from typing_extensions import deprecated
|
|
4
|
+
|
|
3
5
|
from .style import StyledObject
|
|
4
6
|
|
|
5
7
|
|
|
@@ -56,9 +58,8 @@ class Cell(StyledObject):
|
|
|
56
58
|
self.image_height = height
|
|
57
59
|
return self
|
|
58
60
|
|
|
59
|
-
def
|
|
60
|
-
"""
|
|
61
|
-
Set the row and column span for merging.
|
|
61
|
+
def set_span(self, *, rows: int = 1, cols: int = 1) -> Self:
|
|
62
|
+
"""Set the row and column span for merging.
|
|
62
63
|
|
|
63
64
|
Args:
|
|
64
65
|
rows: Number of rows to span.
|
|
@@ -70,3 +71,26 @@ class Cell(StyledObject):
|
|
|
70
71
|
self.row_span = rows
|
|
71
72
|
self.col_span = cols
|
|
72
73
|
return self
|
|
74
|
+
|
|
75
|
+
@deprecated("Use `set_span` instead.")
|
|
76
|
+
def span(self, *, rows: int = 1, cols: int = 1) -> Self:
|
|
77
|
+
"""Set the row and column span for merging.
|
|
78
|
+
|
|
79
|
+
.. deprecated::
|
|
80
|
+
Use :meth:`set_span` instead.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
rows: Number of rows to span.
|
|
84
|
+
cols: Number of columns to span.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The cell itself for chaining.
|
|
88
|
+
"""
|
|
89
|
+
import warnings
|
|
90
|
+
|
|
91
|
+
warnings.warn(
|
|
92
|
+
"Cell.span() is deprecated, use Cell.set_span() instead.",
|
|
93
|
+
DeprecationWarning,
|
|
94
|
+
stacklevel=2,
|
|
95
|
+
)
|
|
96
|
+
return self.set_span(rows=rows, cols=cols)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Self
|
|
2
|
+
|
|
3
|
+
from .style import StyledObject
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Row(StyledObject):
|
|
7
|
+
"""Represents a row in a table."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, index: int) -> None:
|
|
10
|
+
"""
|
|
11
|
+
Initialize a row.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
index: The 0-based index of the row.
|
|
15
|
+
"""
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.index = index
|
|
18
|
+
self.height: int | str = "auto"
|
|
19
|
+
|
|
20
|
+
def set_height(self, height: int | str) -> Self:
|
|
21
|
+
"""
|
|
22
|
+
Set the height of the row.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
height: Height in pixels (int) or CSS value (str, e.g., '50px', '2em').
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The row itself for chaining.
|
|
29
|
+
"""
|
|
30
|
+
self.height = height
|
|
31
|
+
return self
|
|
@@ -10,7 +10,7 @@ class Style:
|
|
|
10
10
|
font_color: str | None = None
|
|
11
11
|
font_family: str | None = None
|
|
12
12
|
font_weight: Literal["normal", "bold"] | None = None
|
|
13
|
-
|
|
13
|
+
background_color: str | None = None
|
|
14
14
|
text_align: Literal["left", "center", "right"] | None = None
|
|
15
15
|
vertical_align: Literal["top", "middle", "bottom"] | None = None
|
|
16
16
|
border_width: int | None = None
|
|
@@ -72,8 +72,8 @@ class Style:
|
|
|
72
72
|
css.append(f"font-family: {', '.join(families)};")
|
|
73
73
|
if self.font_weight:
|
|
74
74
|
css.append(f"font-weight: {self.font_weight};")
|
|
75
|
-
if self.
|
|
76
|
-
css.append(f"background-color: {self.
|
|
75
|
+
if self.background_color:
|
|
76
|
+
css.append(f"background-color: {self.background_color};")
|
|
77
77
|
if self.text_align:
|
|
78
78
|
css.append(f"text-align: {self.text_align};")
|
|
79
79
|
if self.vertical_align:
|
|
@@ -150,7 +150,7 @@ class StyledObject:
|
|
|
150
150
|
Returns:
|
|
151
151
|
The object itself for chaining.
|
|
152
152
|
"""
|
|
153
|
-
self.style.
|
|
153
|
+
self.style.background_color = color
|
|
154
154
|
return self
|
|
155
155
|
|
|
156
156
|
def set_align(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from typing import Self
|
|
2
2
|
|
|
3
|
-
from table_renderer.renderer import
|
|
3
|
+
from table_renderer.renderer import save_html, save_image
|
|
4
4
|
|
|
5
5
|
from .cell import Cell
|
|
6
6
|
from .column import Column
|
|
@@ -20,8 +20,6 @@ class Table(StyledObject):
|
|
|
20
20
|
cols: Initial number of columns.
|
|
21
21
|
"""
|
|
22
22
|
super().__init__()
|
|
23
|
-
self._rows_count = rows
|
|
24
|
-
self._cols_count = cols
|
|
25
23
|
self._cells = [[Cell() for _ in range(cols)] for _ in range(rows)]
|
|
26
24
|
self._row_objects = [Row(i) for i in range(rows)]
|
|
27
25
|
self._col_objects = [Column(i) for i in range(cols)]
|
|
@@ -39,10 +37,7 @@ class Table(StyledObject):
|
|
|
39
37
|
Returns:
|
|
40
38
|
The Cell object.
|
|
41
39
|
"""
|
|
42
|
-
|
|
43
|
-
self._add_row()
|
|
44
|
-
while col_index >= len(self._cells[0]):
|
|
45
|
-
self._add_col()
|
|
40
|
+
self._ensure_capacity(row_index, col_index)
|
|
46
41
|
return self._cells[row_index][col_index]
|
|
47
42
|
|
|
48
43
|
def get_row(self, row_index: int) -> Row:
|
|
@@ -55,8 +50,8 @@ class Table(StyledObject):
|
|
|
55
50
|
Returns:
|
|
56
51
|
The Row object.
|
|
57
52
|
"""
|
|
58
|
-
|
|
59
|
-
|
|
53
|
+
current_cols = len(self._cells[0]) if self._cells else 0
|
|
54
|
+
self._ensure_capacity(row_index, max(0, current_cols - 1))
|
|
60
55
|
return self._row_objects[row_index]
|
|
61
56
|
|
|
62
57
|
def get_column(self, col_index: int) -> Column:
|
|
@@ -69,22 +64,34 @@ class Table(StyledObject):
|
|
|
69
64
|
Returns:
|
|
70
65
|
The Column object.
|
|
71
66
|
"""
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
current_rows = len(self._cells)
|
|
68
|
+
self._ensure_capacity(max(0, current_rows - 1), col_index)
|
|
74
69
|
return self._col_objects[col_index]
|
|
75
70
|
|
|
76
|
-
def
|
|
77
|
-
"""
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
71
|
+
def _ensure_capacity(self, target_row: int, target_col: int) -> None:
|
|
72
|
+
"""Efficiently grow the table to ensure it has at least target_row+1 rows and target_col+1 cols."""
|
|
73
|
+
current_rows = len(self._cells)
|
|
74
|
+
current_cols = len(self._cells[0]) if current_rows > 0 else 0
|
|
75
|
+
|
|
76
|
+
target_rows = max(current_rows, target_row + 1)
|
|
77
|
+
target_cols = max(current_cols, target_col + 1)
|
|
78
|
+
|
|
79
|
+
# 1. Expand existing rows with new columns if needed
|
|
80
|
+
cols_to_add = target_cols - current_cols
|
|
81
|
+
if cols_to_add > 0:
|
|
82
|
+
for row in self._cells:
|
|
83
|
+
row.extend(Cell() for _ in range(cols_to_add))
|
|
84
|
+
self._col_objects.extend(
|
|
85
|
+
Column(i) for i in range(current_cols, target_cols)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# 2. Add new rows with the full target_cols capacity
|
|
89
|
+
rows_to_add = target_rows - current_rows
|
|
90
|
+
if rows_to_add > 0:
|
|
91
|
+
self._cells.extend(
|
|
92
|
+
[Cell() for _ in range(target_cols)] for _ in range(rows_to_add)
|
|
93
|
+
)
|
|
94
|
+
self._row_objects.extend(Row(i) for i in range(current_rows, target_rows))
|
|
88
95
|
|
|
89
96
|
def set_width(self, width: int | str) -> Self:
|
|
90
97
|
"""
|
|
@@ -129,7 +136,7 @@ class Table(StyledObject):
|
|
|
129
136
|
padding: Padding around the table in pixels.
|
|
130
137
|
background_color: Background color of the image.
|
|
131
138
|
"""
|
|
132
|
-
|
|
139
|
+
save_image(
|
|
133
140
|
self,
|
|
134
141
|
output_path,
|
|
135
142
|
dpi=dpi,
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""High-level rendering facade.
|
|
2
|
+
|
|
3
|
+
Orchestrates HTML generation, image rendering, and post-processing.
|
|
4
|
+
Re-exports ``render_to_html`` from ``html_generator`` and delegates
|
|
5
|
+
image rendering to a pluggable ``ImageRenderer`` backend.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from typing_extensions import deprecated
|
|
13
|
+
|
|
14
|
+
from .backends.base import ImageRenderer
|
|
15
|
+
from .backends.weasyprint_renderer import WeasyPrintRenderer
|
|
16
|
+
from .html_generator import render_to_html
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from PIL.Image import Image
|
|
20
|
+
|
|
21
|
+
from .models.table import Table
|
|
22
|
+
|
|
23
|
+
# Default backend instance, lazily initialized on first use.
|
|
24
|
+
_DEFAULT_RENDERER: ImageRenderer | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_default_renderer() -> ImageRenderer:
|
|
28
|
+
"""Return the default renderer, creating a WeasyPrintRenderer on first call."""
|
|
29
|
+
global _DEFAULT_RENDERER
|
|
30
|
+
if _DEFAULT_RENDERER is None:
|
|
31
|
+
_DEFAULT_RENDERER = WeasyPrintRenderer()
|
|
32
|
+
return _DEFAULT_RENDERER
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save_image(
|
|
36
|
+
table: Table,
|
|
37
|
+
output_path: str,
|
|
38
|
+
*,
|
|
39
|
+
dpi: int = 144,
|
|
40
|
+
padding: int = 10,
|
|
41
|
+
background_color: str = "transparent",
|
|
42
|
+
renderer: ImageRenderer | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Render the table and save to an image file.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
table: The Table object to render.
|
|
48
|
+
output_path: Destination path for the image.
|
|
49
|
+
dpi: Target resolution.
|
|
50
|
+
padding: Margin around the table.
|
|
51
|
+
background_color: Background color of the image.
|
|
52
|
+
renderer: Optional custom rendering backend. Defaults to WeasyPrint.
|
|
53
|
+
"""
|
|
54
|
+
renderer = renderer or _get_default_renderer()
|
|
55
|
+
image = renderer.render(
|
|
56
|
+
table, dpi=dpi, padding=padding, background_color=background_color
|
|
57
|
+
)
|
|
58
|
+
_write_pil_image(image, output_path)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@deprecated("Use `save_image` instead.")
|
|
62
|
+
def render_to_image(
|
|
63
|
+
table: Table,
|
|
64
|
+
output_path: str,
|
|
65
|
+
*,
|
|
66
|
+
dpi: int = 144,
|
|
67
|
+
padding: int = 10,
|
|
68
|
+
background_color: str = "transparent",
|
|
69
|
+
renderer: ImageRenderer | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Render the table and save to an image file.
|
|
72
|
+
|
|
73
|
+
.. deprecated::
|
|
74
|
+
Use :func:`save_image` instead.
|
|
75
|
+
"""
|
|
76
|
+
import warnings
|
|
77
|
+
|
|
78
|
+
warnings.warn(
|
|
79
|
+
"render_to_image() is deprecated, use save_image() instead.",
|
|
80
|
+
DeprecationWarning,
|
|
81
|
+
stacklevel=2,
|
|
82
|
+
)
|
|
83
|
+
save_image(
|
|
84
|
+
table,
|
|
85
|
+
output_path,
|
|
86
|
+
dpi=dpi,
|
|
87
|
+
padding=padding,
|
|
88
|
+
background_color=background_color,
|
|
89
|
+
renderer=renderer,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def save_html(
|
|
94
|
+
table: Table, output_path: str, *, background_color: str = "transparent"
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Save the table's HTML representation to a file.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
table: The Table object to export.
|
|
100
|
+
output_path: Path to the destination HTML file.
|
|
101
|
+
background_color: Background color of the HTML body.
|
|
102
|
+
"""
|
|
103
|
+
html_content = render_to_html(table, background_color=background_color)
|
|
104
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
105
|
+
f.write(html_content)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _write_pil_image(image: Image, output_path: str) -> None:
|
|
109
|
+
"""Handle format-specific saving (e.g., JPG alpha flattening).
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
image: A PIL Image to save.
|
|
113
|
+
output_path: Destination file path (format inferred from extension).
|
|
114
|
+
"""
|
|
115
|
+
from PIL import Image
|
|
116
|
+
|
|
117
|
+
if output_path.lower().endswith((".jpg", ".jpeg")) and image.mode == "RGBA":
|
|
118
|
+
rgb_image = Image.new("RGB", image.size, (255, 255, 255))
|
|
119
|
+
rgb_image.paste(image, mask=image.split()[3])
|
|
120
|
+
image = rgb_image
|
|
121
|
+
|
|
122
|
+
image.save(output_path)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<style>
|
|
5
|
+
{{ extra_css }}
|
|
6
|
+
{% for font in font_faces %}
|
|
7
|
+
@font-face {
|
|
8
|
+
font-family: '{{ font.name }}';
|
|
9
|
+
src: url('{{ font.path }}');
|
|
10
|
+
}
|
|
11
|
+
{% endfor %}
|
|
12
|
+
body {
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 20px;
|
|
15
|
+
background-color: {{ background_color }};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
table {
|
|
19
|
+
border-collapse: collapse;
|
|
20
|
+
width: {{ table_width }};
|
|
21
|
+
{{ table_style }}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
td {
|
|
25
|
+
padding: 8px;
|
|
26
|
+
box-sizing: border-box;
|
|
27
|
+
{{ table_style }}
|
|
28
|
+
}
|
|
29
|
+
{% for col in columns %}
|
|
30
|
+
.col-{{ col.index }} {
|
|
31
|
+
width: {{ col.width }};
|
|
32
|
+
{{ col.style }}
|
|
33
|
+
}
|
|
34
|
+
{% endfor %}
|
|
35
|
+
{% for row in rows %}
|
|
36
|
+
.row-{{ row.index }} {
|
|
37
|
+
height: {{ row.height }};
|
|
38
|
+
{{ row.style }}
|
|
39
|
+
}
|
|
40
|
+
{% endfor %}
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
|
|
44
|
+
<body>
|
|
45
|
+
<table>
|
|
46
|
+
{% for r_idx in range(cells|length) %}
|
|
47
|
+
<tr class="row-{{ r_idx }}">
|
|
48
|
+
{% for c_idx in range(cells[r_idx]|length) %}
|
|
49
|
+
{% set cell = cells[r_idx][c_idx] %}
|
|
50
|
+
{% if not cell.is_merged %}
|
|
51
|
+
<td class="row-{{ r_idx }} col-{{ c_idx }}"
|
|
52
|
+
rowspan="{{ cell.row_span }}"
|
|
53
|
+
colspan="{{ cell.col_span }}"
|
|
54
|
+
style="{{ cell.style.to_css() }}">
|
|
55
|
+
{% if cell.image_url %}
|
|
56
|
+
<img src="{{ cell.image_url }}"
|
|
57
|
+
style="{% if cell.image_width %}width: {{ cell.image_width }}px;{% endif %}
|
|
58
|
+
{% if cell.image_height %}height: {{ cell.image_height }}px;{% endif %}">
|
|
59
|
+
<br>
|
|
60
|
+
{% endif %}
|
|
61
|
+
{{ cell.value | replace('\n', '<br>') }}
|
|
62
|
+
</td>
|
|
63
|
+
{% endif %}
|
|
64
|
+
{% endfor %}
|
|
65
|
+
</tr>
|
|
66
|
+
{% endfor %}
|
|
67
|
+
</table>
|
|
68
|
+
</body>
|
|
69
|
+
</html>
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
from .style 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
|
|
@@ -1,267 +0,0 @@
|
|
|
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
|
-
size: {{ page_width }}px {{ page_height }}px; /* Dynamically estimated size */
|
|
17
|
-
margin: 0;
|
|
18
|
-
}
|
|
19
|
-
body {
|
|
20
|
-
margin: 0;
|
|
21
|
-
padding: 20px;
|
|
22
|
-
background-color: {{ background_color }};
|
|
23
|
-
}
|
|
24
|
-
{% for font in font_faces %}
|
|
25
|
-
@font-face {
|
|
26
|
-
font-family: '{{ font.name }}';
|
|
27
|
-
src: url('{{ font.path }}');
|
|
28
|
-
}
|
|
29
|
-
{% endfor %}
|
|
30
|
-
|
|
31
|
-
table {
|
|
32
|
-
border-collapse: collapse;
|
|
33
|
-
width: {{ table_width }};
|
|
34
|
-
{{ table_style }}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
td {
|
|
38
|
-
padding: 8px;
|
|
39
|
-
box-sizing: border-box;
|
|
40
|
-
{{ table_style }}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
{% for col in columns %}
|
|
44
|
-
.col-{{ col.index }} {
|
|
45
|
-
width: {{ col.width }};
|
|
46
|
-
{{ col.style }}
|
|
47
|
-
}
|
|
48
|
-
{% endfor %}
|
|
49
|
-
|
|
50
|
-
{% for row in rows %}
|
|
51
|
-
.row-{{ row.index }} {
|
|
52
|
-
{{ row.style }}
|
|
53
|
-
}
|
|
54
|
-
{% endfor %}
|
|
55
|
-
</style>
|
|
56
|
-
</head>
|
|
57
|
-
<body>
|
|
58
|
-
<table>
|
|
59
|
-
{% for r_idx in range(cells|length) %}
|
|
60
|
-
<tr class="row-{{ r_idx }}">
|
|
61
|
-
{% for c_idx in range(cells[r_idx]|length) %}
|
|
62
|
-
{% set cell = cells[r_idx][c_idx] %}
|
|
63
|
-
{% if not cell.is_merged %}
|
|
64
|
-
<td class="row-{{ r_idx }} col-{{ c_idx }}"
|
|
65
|
-
rowspan="{{ cell.row_span }}"
|
|
66
|
-
colspan="{{ cell.col_span }}"
|
|
67
|
-
style="{{ cell.style.to_css() }}">
|
|
68
|
-
{% if cell.image_url %}
|
|
69
|
-
<img src="{{ cell.image_url }}"
|
|
70
|
-
style="{% if cell.image_width %}width: {{ cell.image_width }}px;{% endif %}
|
|
71
|
-
{% if cell.image_height %}height: {{ cell.image_height }}px;{% endif %}">
|
|
72
|
-
<br>
|
|
73
|
-
{% endif %}
|
|
74
|
-
{{ cell.value | replace('\\n', '<br>') }}
|
|
75
|
-
</td>
|
|
76
|
-
{% endif %}
|
|
77
|
-
{% endfor %}
|
|
78
|
-
</tr>
|
|
79
|
-
{% endfor %}
|
|
80
|
-
</table>
|
|
81
|
-
</body>
|
|
82
|
-
</html>
|
|
83
|
-
"""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def render_to_html(table: "Table", *, background_color: str = "transparent") -> str:
|
|
87
|
-
"""
|
|
88
|
-
Generate the HTML representation of the table.
|
|
89
|
-
|
|
90
|
-
Args:
|
|
91
|
-
table: The Table object to render.
|
|
92
|
-
background_color: The background color of the body.
|
|
93
|
-
|
|
94
|
-
Returns:
|
|
95
|
-
A string containing the complete HTML document.
|
|
96
|
-
"""
|
|
97
|
-
table._prepare_render()
|
|
98
|
-
|
|
99
|
-
# Calculate final styles for each cell based on inheritance
|
|
100
|
-
font_faces = []
|
|
101
|
-
for font_path in table.font_files:
|
|
102
|
-
name = os.path.splitext(os.path.basename(font_path))[0]
|
|
103
|
-
# Convert path to absolute file URL for WeasyPrint
|
|
104
|
-
abs_path = os.path.abspath(font_path)
|
|
105
|
-
font_faces.append({"name": name, "path": f"file://{abs_path}"})
|
|
106
|
-
|
|
107
|
-
cells_data = []
|
|
108
|
-
for row_index in range(len(table._cells)):
|
|
109
|
-
row_data = []
|
|
110
|
-
for col_index in range(len(table._cells[0])):
|
|
111
|
-
cell = table._cells[row_index][col_index]
|
|
112
|
-
|
|
113
|
-
# Handle Image URL resolution
|
|
114
|
-
cell.image_url = ""
|
|
115
|
-
if cell.image_path:
|
|
116
|
-
if cell.image_path.startswith(("http://", "https://")):
|
|
117
|
-
cell.image_url = cell.image_path
|
|
118
|
-
else:
|
|
119
|
-
abs_img_path = os.path.abspath(cell.image_path)
|
|
120
|
-
cell.image_url = f"file://{abs_img_path}"
|
|
121
|
-
|
|
122
|
-
row_data.append(cell)
|
|
123
|
-
cells_data.append(row_data)
|
|
124
|
-
|
|
125
|
-
# Estimate safe canvas width to prevent clipping
|
|
126
|
-
# Base it on the number of columns if explicit widths aren't provided (assume ~100px min per column)
|
|
127
|
-
col_count = len(table._cells[0]) if table._cells else 0
|
|
128
|
-
estimated_width = max(2000, col_count * 100)
|
|
129
|
-
|
|
130
|
-
if isinstance(table.width, int):
|
|
131
|
-
estimated_width = max(estimated_width, table.width + 100)
|
|
132
|
-
|
|
133
|
-
col_width_sum = 0
|
|
134
|
-
for c in table._col_objects:
|
|
135
|
-
if isinstance(c.width, int):
|
|
136
|
-
col_width_sum += c.width
|
|
137
|
-
estimated_width = max(estimated_width, col_width_sum + 100)
|
|
138
|
-
|
|
139
|
-
# Estimate safe canvas height to optimize rendering speed for small tables, capped at 5000px
|
|
140
|
-
# Assume a generous average of 40px per row plus 100px padding
|
|
141
|
-
estimated_height = len(table._cells) * 40 + 100
|
|
142
|
-
estimated_height = min(max(estimated_height, 500), 5000)
|
|
143
|
-
|
|
144
|
-
template = Template(HTML_TEMPLATE)
|
|
145
|
-
return template.render(
|
|
146
|
-
page_width=estimated_width,
|
|
147
|
-
page_height=estimated_height,
|
|
148
|
-
background_color=background_color,
|
|
149
|
-
table_width=f"{table.width}px" if isinstance(table.width, int) else table.width,
|
|
150
|
-
table_style=table.style.to_css(),
|
|
151
|
-
font_faces=font_faces,
|
|
152
|
-
columns=[
|
|
153
|
-
{
|
|
154
|
-
"index": c.index,
|
|
155
|
-
"width": f"{c.width}px" if isinstance(c.width, int) else c.width,
|
|
156
|
-
"style": c.style.to_css(),
|
|
157
|
-
}
|
|
158
|
-
for c in table._col_objects
|
|
159
|
-
],
|
|
160
|
-
rows=[
|
|
161
|
-
{"index": r.index, "style": r.style.to_css()} for r in table._row_objects
|
|
162
|
-
],
|
|
163
|
-
cells=cells_data,
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def render_to_image(
|
|
168
|
-
table: "Table",
|
|
169
|
-
output_path: str,
|
|
170
|
-
*,
|
|
171
|
-
dpi: int = 144,
|
|
172
|
-
padding: int = 10,
|
|
173
|
-
background_color: str = "transparent",
|
|
174
|
-
) -> None:
|
|
175
|
-
"""
|
|
176
|
-
Render the table to an image file.
|
|
177
|
-
|
|
178
|
-
Args:
|
|
179
|
-
table: The Table object to render.
|
|
180
|
-
output_path: Destination path for the image.
|
|
181
|
-
dpi: Target resolution.
|
|
182
|
-
padding: Margin around the table.
|
|
183
|
-
background_color: Background color of the image.
|
|
184
|
-
"""
|
|
185
|
-
html_content = render_to_html(table, background_color=background_color)
|
|
186
|
-
# WeasyPrint v53+ removed write_png, so we render to PDF then convert to PNG
|
|
187
|
-
pdf_bytes = HTML(string=html_content).write_pdf()
|
|
188
|
-
|
|
189
|
-
import pypdfium2 as pdfium
|
|
190
|
-
from PIL import Image
|
|
191
|
-
|
|
192
|
-
# Load the PDF from bytes
|
|
193
|
-
with pdfium.PdfDocument(pdf_bytes) as pdf:
|
|
194
|
-
page_images = []
|
|
195
|
-
scale_factor = dpi / 72
|
|
196
|
-
|
|
197
|
-
# Render each page to a PIL Image
|
|
198
|
-
for page in pdf:
|
|
199
|
-
# fill_color=(255, 255, 255, 0) ensures the background is transparent
|
|
200
|
-
bitmap = page.render(scale=scale_factor, fill_color=(255, 255, 255, 0))
|
|
201
|
-
pil_page = bitmap.to_pil()
|
|
202
|
-
page_images.append(pil_page)
|
|
203
|
-
bitmap.close()
|
|
204
|
-
|
|
205
|
-
if not page_images:
|
|
206
|
-
return
|
|
207
|
-
|
|
208
|
-
# Stitch all pages vertically into one long image
|
|
209
|
-
total_width = max(img.width for img in page_images)
|
|
210
|
-
total_height = sum(img.height for img in page_images)
|
|
211
|
-
|
|
212
|
-
# Create a large canvas
|
|
213
|
-
pil_image = Image.new("RGBA", (total_width, total_height), (255, 255, 255, 0))
|
|
214
|
-
y_offset = 0
|
|
215
|
-
for img in page_images:
|
|
216
|
-
pil_image.paste(img, (0, y_offset))
|
|
217
|
-
y_offset += img.height
|
|
218
|
-
|
|
219
|
-
# Visual Auto-Crop: Find the bounding box of non-white pixels
|
|
220
|
-
# Use 'L' (grayscale) mode to reduce memory usage (1 byte per pixel vs 3)
|
|
221
|
-
if pil_image.mode == "RGBA":
|
|
222
|
-
# Flatten RGBA onto a white grayscale background
|
|
223
|
-
grayscale = Image.new("L", pil_image.size, 255)
|
|
224
|
-
grayscale.paste(pil_image.convert("L"), mask=pil_image.getchannel("A"))
|
|
225
|
-
else:
|
|
226
|
-
# For RGB or other modes, just convert to grayscale
|
|
227
|
-
grayscale = pil_image.convert("L")
|
|
228
|
-
|
|
229
|
-
# Invert and find bbox of "ink" (non-white pixels)
|
|
230
|
-
import PIL.ImageOps
|
|
231
|
-
|
|
232
|
-
inverted = PIL.ImageOps.invert(grayscale)
|
|
233
|
-
bbox = inverted.getbbox()
|
|
234
|
-
|
|
235
|
-
if bbox:
|
|
236
|
-
# Add some padding
|
|
237
|
-
left, top, right, bottom = bbox
|
|
238
|
-
left = max(0, left - padding)
|
|
239
|
-
top = max(0, top - padding)
|
|
240
|
-
right = min(pil_image.width, right + padding)
|
|
241
|
-
bottom = min(pil_image.height, bottom + padding)
|
|
242
|
-
pil_image = pil_image.crop((left, top, right, bottom))
|
|
243
|
-
|
|
244
|
-
# Save to the requested format (Pillow handles most formats)
|
|
245
|
-
if output_path.lower().endswith((".jpg", ".jpeg")) and pil_image.mode == "RGBA":
|
|
246
|
-
# Create a white background image and paste the RGBA image on it to flatten it
|
|
247
|
-
rgb_image = Image.new("RGB", pil_image.size, (255, 255, 255))
|
|
248
|
-
rgb_image.paste(pil_image, mask=pil_image.split()[3])
|
|
249
|
-
pil_image = rgb_image
|
|
250
|
-
|
|
251
|
-
pil_image.save(output_path)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def save_html(
|
|
255
|
-
table: "Table", output_path: str, *, background_color: str = "transparent"
|
|
256
|
-
) -> None:
|
|
257
|
-
"""
|
|
258
|
-
Save the table's HTML representation to a file.
|
|
259
|
-
|
|
260
|
-
Args:
|
|
261
|
-
table: The Table object to export.
|
|
262
|
-
output_path: Path to the destination HTML file.
|
|
263
|
-
background_color: Background color of the HTML body.
|
|
264
|
-
"""
|
|
265
|
-
html_content = render_to_html(table, background_color=background_color)
|
|
266
|
-
with open(output_path, "w", encoding="utf-8") as f:
|
|
267
|
-
f.write(html_content)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|