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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: table-renderer
3
- Version: 0.4.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").span(rows=1, cols=2)
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).span(rows=1, cols=2).set_text("Merged Header")
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").span(rows=1, cols=2)
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).span(rows=1, cols=2).set_text("Merged Header")
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.4.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.4.0"
9
+ __version__ = "0.5.0"
10
10
 
11
11
  __all__ = ["Cell", "Column", "Row", "Style", "StyledObject", "Table", "__version__"]
@@ -0,0 +1,6 @@
1
+ """Rendering backends for table-renderer."""
2
+
3
+ from .base import ImageRenderer
4
+ from .weasyprint_renderer import WeasyPrintRenderer
5
+
6
+ __all__ = ["ImageRenderer", "WeasyPrintRenderer"]
@@ -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 span(self, *, rows: int = 1, cols: int = 1) -> Self:
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
- bg_color: str | None = None
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.bg_color:
76
- css.append(f"background-color: {self.bg_color};")
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.bg_color = color
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 render_to_image, save_html
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
- while row_index >= len(self._cells):
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
- while row_index >= len(self._row_objects):
59
- self._add_row()
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
- while col_index >= len(self._col_objects):
73
- self._add_col()
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 _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)))
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
- render_to_image(
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)