table-renderer 0.3.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.3.0 → table_renderer-0.5.0}/PKG-INFO +11 -9
- {table_renderer-0.3.0 → table_renderer-0.5.0}/README.md +9 -8
- {table_renderer-0.3.0 → table_renderer-0.5.0}/pyproject.toml +2 -1
- {table_renderer-0.3.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.3.0 → table_renderer-0.5.0}/src/table_renderer/models/__init__.py +1 -2
- {table_renderer-0.3.0 → table_renderer-0.5.0}/src/table_renderer/models/cell.py +28 -4
- {table_renderer-0.3.0 → table_renderer-0.5.0}/src/table_renderer/models/column.py +1 -1
- table_renderer-0.5.0/src/table_renderer/models/row.py +31 -0
- {table_renderer-0.3.0 → table_renderer-0.5.0}/src/table_renderer/models/style.py +94 -4
- {table_renderer-0.3.0 → table_renderer-0.5.0}/src/table_renderer/models/table.py +32 -25
- 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.3.0/src/table_renderer/models/base.py +0 -93
- table_renderer-0.3.0/src/table_renderer/models/row.py +0 -15
- table_renderer-0.3.0/src/table_renderer/renderer.py +0 -267
- {table_renderer-0.3.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
|
|
@@ -44,7 +45,7 @@ Description-Content-Type: text/markdown
|
|
|
44
45
|
- **Auto-Crop**: Automatically crops images to the actual table area using visual detection, eliminating unnecessary white space.
|
|
45
46
|
- **Continuous Long Tables**: Automatically stitches multiple PDF pages into a single continuous long image, preventing content from being cut off.
|
|
46
47
|
- **Object-Oriented API**: Clean `Table` -> `Row/Column` -> `Cell` hierarchy with fluent interface support.
|
|
47
|
-
- **Cascading Styles**: Manage visual styles effortlessly with
|
|
48
|
+
- **Cascading Styles**: Manage visual styles effortlessly with logical precedence: `Cell` > `Row` > `Column` > `Table`.
|
|
48
49
|
- **Multi-language Font Support**: Support for font stacks (e.g., different fonts for English and Chinese).
|
|
49
50
|
- **Customizable Resolution**: Adjustable DPI for high-resolution output (e.g., 300 DPI for print).
|
|
50
51
|
- **Lightweight Deployment**: No heavy browser dependencies (like Chromium), making it ideal for Docker/K8s.
|
|
@@ -82,8 +83,8 @@ from table_renderer import Table
|
|
|
82
83
|
|
|
83
84
|
# 1. Initialize a 3x3 table
|
|
84
85
|
table = Table(3, 3)
|
|
85
|
-
table.set_width(600)
|
|
86
86
|
table.set_border(width=1, color="black", style="solid")
|
|
87
|
+
table.set_width(400)
|
|
87
88
|
|
|
88
89
|
# 2. Set header style (Row 0)
|
|
89
90
|
header = table.get_row(0)
|
|
@@ -95,7 +96,7 @@ table.cell(0, 1).set_text("Category")
|
|
|
95
96
|
table.cell(0, 2).set_text("Price")
|
|
96
97
|
|
|
97
98
|
# 3. Add content and demonstrate merging
|
|
98
|
-
table.cell(1, 0).set_text("MacBook Pro").
|
|
99
|
+
table.cell(1, 0).set_text("MacBook Pro").set_span(rows=1, cols=2)
|
|
99
100
|
table.cell(1, 2).set_text("$2,000")
|
|
100
101
|
|
|
101
102
|
table.cell(2, 0).set_text("Magic Mouse")
|
|
@@ -105,8 +106,8 @@ table.cell(2, 2).set_text("$79")
|
|
|
105
106
|
# 4. Global column styling (align prices to the right)
|
|
106
107
|
table.get_column(2).set_align(horizontal="right").set_width(100)
|
|
107
108
|
|
|
108
|
-
# 5. Export to image with custom DPI and
|
|
109
|
-
table.to_image("report.png", dpi=300, padding=20)
|
|
109
|
+
# 5. Export to image with custom DPI, padding, and background color
|
|
110
|
+
table.to_image("report.png", dpi=300, padding=20, background_color="white")
|
|
110
111
|
```
|
|
111
112
|
|
|
112
113
|
---
|
|
@@ -119,7 +120,7 @@ Use the anchor-based design to merge cells by specifying the starting cell and t
|
|
|
119
120
|
|
|
120
121
|
```python
|
|
121
122
|
# Merge first row, first two columns (1 row, 2 columns)
|
|
122
|
-
table.cell(0, 0).
|
|
123
|
+
table.cell(0, 0).set_span(rows=1, cols=2).set_text("Merged Header")
|
|
123
124
|
```
|
|
124
125
|
|
|
125
126
|
### 2. Font Management
|
|
@@ -149,12 +150,13 @@ table.cell(1, 1).set_image("https://example.com/image.jpg", width=100)
|
|
|
149
150
|
|
|
150
151
|
### 4. Layout & Sizing
|
|
151
152
|
|
|
152
|
-
Mix fixed widths, percentages, and auto-sizing.
|
|
153
|
+
Mix fixed widths, heights, percentages, and auto-sizing.
|
|
153
154
|
|
|
154
155
|
```python
|
|
155
156
|
table.set_width(800) # Total table width
|
|
156
|
-
table.get_column(0).set_width(200) # Fixed 200px
|
|
157
|
+
table.get_column(0).set_width(200) # Fixed column width (200px)
|
|
157
158
|
table.get_column(1).set_width("auto") # Distribute remaining space
|
|
159
|
+
table.get_row(0).set_height(100) # Fixed row height (100px)
|
|
158
160
|
```
|
|
159
161
|
|
|
160
162
|
**Handling Extremely Wide Tables:**
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
- **Auto-Crop**: Automatically crops images to the actual table area using visual detection, eliminating unnecessary white space.
|
|
22
22
|
- **Continuous Long Tables**: Automatically stitches multiple PDF pages into a single continuous long image, preventing content from being cut off.
|
|
23
23
|
- **Object-Oriented API**: Clean `Table` -> `Row/Column` -> `Cell` hierarchy with fluent interface support.
|
|
24
|
-
- **Cascading Styles**: Manage visual styles effortlessly with
|
|
24
|
+
- **Cascading Styles**: Manage visual styles effortlessly with logical precedence: `Cell` > `Row` > `Column` > `Table`.
|
|
25
25
|
- **Multi-language Font Support**: Support for font stacks (e.g., different fonts for English and Chinese).
|
|
26
26
|
- **Customizable Resolution**: Adjustable DPI for high-resolution output (e.g., 300 DPI for print).
|
|
27
27
|
- **Lightweight Deployment**: No heavy browser dependencies (like Chromium), making it ideal for Docker/K8s.
|
|
@@ -59,8 +59,8 @@ from table_renderer import Table
|
|
|
59
59
|
|
|
60
60
|
# 1. Initialize a 3x3 table
|
|
61
61
|
table = Table(3, 3)
|
|
62
|
-
table.set_width(600)
|
|
63
62
|
table.set_border(width=1, color="black", style="solid")
|
|
63
|
+
table.set_width(400)
|
|
64
64
|
|
|
65
65
|
# 2. Set header style (Row 0)
|
|
66
66
|
header = table.get_row(0)
|
|
@@ -72,7 +72,7 @@ table.cell(0, 1).set_text("Category")
|
|
|
72
72
|
table.cell(0, 2).set_text("Price")
|
|
73
73
|
|
|
74
74
|
# 3. Add content and demonstrate merging
|
|
75
|
-
table.cell(1, 0).set_text("MacBook Pro").
|
|
75
|
+
table.cell(1, 0).set_text("MacBook Pro").set_span(rows=1, cols=2)
|
|
76
76
|
table.cell(1, 2).set_text("$2,000")
|
|
77
77
|
|
|
78
78
|
table.cell(2, 0).set_text("Magic Mouse")
|
|
@@ -82,8 +82,8 @@ table.cell(2, 2).set_text("$79")
|
|
|
82
82
|
# 4. Global column styling (align prices to the right)
|
|
83
83
|
table.get_column(2).set_align(horizontal="right").set_width(100)
|
|
84
84
|
|
|
85
|
-
# 5. Export to image with custom DPI and
|
|
86
|
-
table.to_image("report.png", dpi=300, padding=20)
|
|
85
|
+
# 5. Export to image with custom DPI, padding, and background color
|
|
86
|
+
table.to_image("report.png", dpi=300, padding=20, background_color="white")
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
---
|
|
@@ -96,7 +96,7 @@ Use the anchor-based design to merge cells by specifying the starting cell and t
|
|
|
96
96
|
|
|
97
97
|
```python
|
|
98
98
|
# Merge first row, first two columns (1 row, 2 columns)
|
|
99
|
-
table.cell(0, 0).
|
|
99
|
+
table.cell(0, 0).set_span(rows=1, cols=2).set_text("Merged Header")
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
### 2. Font Management
|
|
@@ -126,12 +126,13 @@ table.cell(1, 1).set_image("https://example.com/image.jpg", width=100)
|
|
|
126
126
|
|
|
127
127
|
### 4. Layout & Sizing
|
|
128
128
|
|
|
129
|
-
Mix fixed widths, percentages, and auto-sizing.
|
|
129
|
+
Mix fixed widths, heights, percentages, and auto-sizing.
|
|
130
130
|
|
|
131
131
|
```python
|
|
132
132
|
table.set_width(800) # Total table width
|
|
133
|
-
table.get_column(0).set_width(200) # Fixed 200px
|
|
133
|
+
table.get_column(0).set_width(200) # Fixed column width (200px)
|
|
134
134
|
table.get_column(1).set_width("auto") # Distribute remaining space
|
|
135
|
+
table.get_row(0).set_height(100) # Fixed row height (100px)
|
|
135
136
|
```
|
|
136
137
|
|
|
137
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,8 +1,7 @@
|
|
|
1
|
-
from .base import StyledObject
|
|
2
1
|
from .cell import Cell
|
|
3
2
|
from .column import Column
|
|
4
3
|
from .row import Row
|
|
5
|
-
from .style import Style
|
|
4
|
+
from .style import Style, StyledObject
|
|
6
5
|
from .table import Table
|
|
7
6
|
|
|
8
7
|
__all__ = ["Cell", "Column", "Row", "Style", "StyledObject", "Table"]
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from typing import Self
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from typing_extensions import deprecated
|
|
4
|
+
|
|
5
|
+
from .style import StyledObject
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
class Cell(StyledObject):
|
|
@@ -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)
|