table-renderer 0.4.0__tar.gz → 0.6.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.
Files changed (25) hide show
  1. {table_renderer-0.4.0 → table_renderer-0.6.0}/PKG-INFO +8 -5
  2. {table_renderer-0.4.0 → table_renderer-0.6.0}/README.md +6 -4
  3. table_renderer-0.6.0/pyproject.toml +105 -0
  4. table_renderer-0.4.0/pyproject.toml → table_renderer-0.6.0/pyproject.toml.orig +7 -4
  5. table_renderer-0.6.0/src/table_renderer/__init__.py +33 -0
  6. table_renderer-0.6.0/src/table_renderer/backends/__init__.py +6 -0
  7. table_renderer-0.6.0/src/table_renderer/backends/base.py +44 -0
  8. table_renderer-0.6.0/src/table_renderer/backends/geometry.py +287 -0
  9. table_renderer-0.6.0/src/table_renderer/backends/pdf_rasterizer.py +235 -0
  10. table_renderer-0.6.0/src/table_renderer/backends/weasyprint_renderer.py +206 -0
  11. table_renderer-0.6.0/src/table_renderer/html_generator.py +125 -0
  12. table_renderer-0.6.0/src/table_renderer/models/cell.py +146 -0
  13. table_renderer-0.6.0/src/table_renderer/models/row.py +31 -0
  14. {table_renderer-0.4.0 → table_renderer-0.6.0}/src/table_renderer/models/style.py +62 -28
  15. {table_renderer-0.4.0 → table_renderer-0.6.0}/src/table_renderer/models/table.py +36 -54
  16. table_renderer-0.6.0/src/table_renderer/renderer.py +122 -0
  17. table_renderer-0.6.0/src/table_renderer/resolved_table.py +172 -0
  18. table_renderer-0.6.0/src/table_renderer/templates/table.html.j2 +69 -0
  19. table_renderer-0.4.0/src/table_renderer/__init__.py +0 -11
  20. table_renderer-0.4.0/src/table_renderer/models/cell.py +0 -72
  21. table_renderer-0.4.0/src/table_renderer/models/row.py +0 -15
  22. table_renderer-0.4.0/src/table_renderer/renderer.py +0 -267
  23. {table_renderer-0.4.0 → table_renderer-0.6.0}/src/table_renderer/models/__init__.py +0 -0
  24. {table_renderer-0.4.0 → table_renderer-0.6.0}/src/table_renderer/models/column.py +0 -0
  25. {table_renderer-0.4.0 → table_renderer-0.6.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.4.0
3
+ Version: 0.6.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:**
@@ -0,0 +1,105 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.11.0,<0.14.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "table-renderer"
7
+ version = "0.6.0"
8
+ description = "A Python library for rendering structured tables as high-quality images or HTML."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.12,<3.15"
12
+ keywords = [
13
+ "table",
14
+ "renderer",
15
+ "rendering",
16
+ "image",
17
+ "html",
18
+ "weasyprint",
19
+ "pdfium",
20
+ "report",
21
+ ]
22
+ classifiers = [
23
+ "Development Status :: 4 - Beta",
24
+ "Intended Audience :: Developers",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Operating System :: OS Independent",
29
+ "Topic :: Multimedia :: Graphics",
30
+ "Topic :: Software Development :: Libraries :: Python Modules",
31
+ ]
32
+ dependencies = [
33
+ "jinja2~=3.0",
34
+ "pillow~=12.0",
35
+ "pypdfium2~=5.0",
36
+ "typing-extensions>=4.5.0",
37
+ "weasyprint>=68.1",
38
+ ]
39
+
40
+ [[project.authors]]
41
+ name = "pTaunium"
42
+ email = "pTaunium@gmail.com"
43
+
44
+ [dependency-groups]
45
+ dev = [
46
+ "ipython",
47
+ "pytest~=9.0",
48
+ "pytest-cov~=7.0",
49
+ "ruff~=0.17",
50
+ "ty>=0.0.95",
51
+ ]
52
+
53
+ [tool.ruff]
54
+ line-length = 88
55
+ indent-width = 4
56
+ extend-exclude = ["*.md"]
57
+
58
+ [tool.ruff.lint]
59
+ select = [
60
+ "E",
61
+ "W",
62
+ "I",
63
+ "N",
64
+ "F",
65
+ "C90",
66
+ "ANN",
67
+ "ASYNC",
68
+ "S",
69
+ "B",
70
+ "A",
71
+ "C4",
72
+ "PIE",
73
+ "PT",
74
+ "RET",
75
+ "SIM",
76
+ "PERF",
77
+ "FURB",
78
+ "UP",
79
+ "RUF",
80
+ ]
81
+ ignore = ["E501"]
82
+
83
+ [tool.ruff.lint.isort]
84
+ section-order = [
85
+ "future",
86
+ "standard-library",
87
+ "third-party",
88
+ "first-party",
89
+ "local-folder",
90
+ ]
91
+
92
+ [tool.ruff.lint.per-file-ignores]
93
+ "tests/**/*.py" = [
94
+ "S101",
95
+ "S105",
96
+ "S106",
97
+ "S107",
98
+ ]
99
+
100
+ [tool.ruff.format]
101
+ docstring-code-format = true
102
+ docstring-code-line-length = "dynamic"
103
+
104
+ [tool.coverage.run]
105
+ source = ["src"]
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["uv_build>=0.11.0,<0.12.0"]
2
+ requires = ["uv_build>=0.11.0,<0.14.0"]
3
3
  build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "table-renderer"
7
- version = "0.4.0"
7
+ version = "0.6.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
 
@@ -36,13 +37,15 @@ dev = [
36
37
  "ipython",
37
38
  "pytest~=9.0",
38
39
  "pytest-cov~=7.0",
39
- "ruff~=0.15",
40
- "ty>=0.0.41",
40
+ "ruff~=0.17",
41
+ "ty>=0.0.95",
41
42
  ]
42
43
 
43
44
  [tool.ruff]
44
45
  line-length = 88
45
46
  indent-width = 4
47
+ # Keep hand-aligned comments in README code blocks.
48
+ extend-exclude = ["*.md"]
46
49
 
47
50
  [tool.ruff.lint]
48
51
  select = [
@@ -0,0 +1,33 @@
1
+ import importlib.metadata
2
+
3
+ from .models import Cell, Column, Row, Style, StyledObject, Table
4
+ from .resolved_table import (
5
+ FontFace,
6
+ ResolvedCell,
7
+ ResolvedColumn,
8
+ ResolvedRow,
9
+ ResolvedTable,
10
+ resolve_table,
11
+ )
12
+
13
+ try:
14
+ __version__ = importlib.metadata.version("table-renderer")
15
+ except importlib.metadata.PackageNotFoundError:
16
+ # Package is not installed (e.g. during local development without pip install -e .)
17
+ __version__ = "0.6.0"
18
+
19
+ __all__ = [
20
+ "Cell",
21
+ "Column",
22
+ "FontFace",
23
+ "ResolvedCell",
24
+ "ResolvedColumn",
25
+ "ResolvedRow",
26
+ "ResolvedTable",
27
+ "Row",
28
+ "Style",
29
+ "StyledObject",
30
+ "Table",
31
+ "__version__",
32
+ "resolve_table",
33
+ ]
@@ -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,287 @@
1
+ """Layout geometry calculations for table rendering.
2
+
3
+ This module is a deep module that encapsulates all unit-conversion,
4
+ length-parsing, page-size estimation, overflow checks, and crop-box
5
+ calculations. It deals purely with numbers and ResolvedTable dimensions,
6
+ independent of any PDF engine or browser renderer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from table_renderer.resolved_table import ResolvedTable
18
+
19
+ CSS_DPI: int = 96
20
+ PDF_DPI: int = 72
21
+ DEFAULT_COL_WIDTH_PX: int = 100
22
+ MIN_PAGE_WIDTH_PX: int = 200
23
+ DEFAULT_BODY_PADDING_CSS_PX: int = 20
24
+ BODY_PADDING_BUFFER_CSS_PX: int = 4
25
+ WIDTH_MARGIN_ALLOWANCE_CSS_PX: int = 20
26
+ HEIGHT_SAFETY_BUFFER_CSS_PX: int = 100
27
+ INITIAL_PAGE_HEIGHT_CSS_PX: int = 1_000_000
28
+ BORDER_SLACK_DEV_PX: int = 1
29
+ MAX_BODY_PADDING_ALIGNMENT_STEP_CSS_PX: int = 8
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class TableBounds:
34
+ """Bounding box of a rendered table in CSS pixels."""
35
+
36
+ x: float
37
+ y: float
38
+ width: float
39
+ height: float
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class PageDimensions:
44
+ """Dimensions of a rendering canvas/page in CSS pixels."""
45
+
46
+ width: int
47
+ height: int
48
+ body_padding_css_px: int
49
+
50
+
51
+ def parse_pixel_length(value: int | str) -> int | None:
52
+ """Parse an integer or CSS pixel length string into integer pixels.
53
+
54
+ Args:
55
+ value: An integer pixel value or CSS length string (e.g., ``"1200px"``).
56
+
57
+ Returns:
58
+ Positive integer pixel count, or ``None`` if not a pixel value.
59
+ """
60
+ if isinstance(value, bool):
61
+ return None
62
+ if isinstance(value, int):
63
+ return value if value > 0 else None
64
+ if isinstance(value, str):
65
+ stripped = value.strip().lower()
66
+ if stripped.endswith("px"):
67
+ stripped = stripped[:-2].strip()
68
+ try:
69
+ parsed = float(stripped)
70
+ except ValueError:
71
+ return None
72
+ if parsed > 0 and math.isfinite(parsed):
73
+ return math.ceil(parsed)
74
+ return None
75
+
76
+
77
+ def compute_body_padding_css_px(*, dpi: int, padding: int) -> int:
78
+ """Compute the CSS body padding required to accommodate device-pixel padding.
79
+
80
+ Args:
81
+ dpi: Target resolution in dots per inch.
82
+ padding: Padding in device pixels.
83
+
84
+ Returns:
85
+ CSS body padding in pixels.
86
+ """
87
+ device_px_per_css_px = dpi / CSS_DPI
88
+ raw_body_padding_css_px = max(
89
+ DEFAULT_BODY_PADDING_CSS_PX,
90
+ math.ceil(max(0, padding) / device_px_per_css_px) + BODY_PADDING_BUFFER_CSS_PX,
91
+ )
92
+ # Align CSS body padding to device pixel grid, capping alignment step to avoid runaway page expansion
93
+ body_padding_alignment_step_css_px = min(
94
+ MAX_BODY_PADDING_ALIGNMENT_STEP_CSS_PX,
95
+ CSS_DPI // math.gcd(dpi, CSS_DPI),
96
+ )
97
+ return (
98
+ math.ceil(raw_body_padding_css_px / body_padding_alignment_step_css_px)
99
+ * body_padding_alignment_step_css_px
100
+ )
101
+
102
+
103
+ def estimate_page_width(
104
+ resolved: ResolvedTable,
105
+ *,
106
+ body_padding_css_px: int = DEFAULT_BODY_PADDING_CSS_PX,
107
+ ) -> int:
108
+ """Estimate the required table page width in CSS pixels.
109
+
110
+ Args:
111
+ resolved: The ResolvedTable to measure.
112
+ body_padding_css_px: CSS body padding in pixels.
113
+
114
+ Returns:
115
+ Estimated page width in CSS pixels.
116
+ """
117
+ margin_allowance_css_px = body_padding_css_px * 2 + WIDTH_MARGIN_ALLOWANCE_CSS_PX
118
+ if not resolved.cells:
119
+ return max(MIN_PAGE_WIDTH_PX, margin_allowance_css_px)
120
+
121
+ parsed_table_width = parse_pixel_length(resolved.width)
122
+ if parsed_table_width is not None:
123
+ return max(MIN_PAGE_WIDTH_PX, parsed_table_width + margin_allowance_css_px)
124
+
125
+ total_width_css_px = 0
126
+ for col in resolved.columns:
127
+ parsed_col_width = parse_pixel_length(col.width)
128
+ if parsed_col_width is not None:
129
+ total_width_css_px += parsed_col_width
130
+ else:
131
+ total_width_css_px += DEFAULT_COL_WIDTH_PX
132
+
133
+ return max(MIN_PAGE_WIDTH_PX, total_width_css_px + margin_allowance_css_px)
134
+
135
+
136
+ def estimate_page_dimensions(
137
+ resolved: ResolvedTable,
138
+ *,
139
+ dpi: int,
140
+ padding: int,
141
+ ) -> PageDimensions:
142
+ """Estimate initial continuous page dimensions for a table.
143
+
144
+ Args:
145
+ resolved: The ResolvedTable to measure.
146
+ dpi: Target resolution in dots per inch.
147
+ padding: Requested image padding in device pixels.
148
+
149
+ Returns:
150
+ PageDimensions with width, initial page height, and body padding.
151
+ """
152
+ body_padding_css_px = compute_body_padding_css_px(dpi=dpi, padding=padding)
153
+ width = estimate_page_width(resolved, body_padding_css_px=body_padding_css_px)
154
+ return PageDimensions(
155
+ width=width,
156
+ height=INITIAL_PAGE_HEIGHT_CSS_PX,
157
+ body_padding_css_px=body_padding_css_px,
158
+ )
159
+
160
+
161
+ def build_page_css(dimensions: PageDimensions) -> str:
162
+ """Build ``@page`` and ``html body`` CSS rules for continuous layout.
163
+
164
+ Args:
165
+ dimensions: The target page dimensions and padding.
166
+
167
+ Returns:
168
+ CSS string suitable for injection into ``extra_css``.
169
+ """
170
+ return (
171
+ "@page {\n"
172
+ f" size: {dimensions.width}px {dimensions.height}px;\n"
173
+ " margin: 0;\n"
174
+ "}\n"
175
+ "html body {\n"
176
+ f" padding: {dimensions.body_padding_css_px}px;\n"
177
+ "}"
178
+ )
179
+
180
+
181
+ def compute_relayout_dimensions(
182
+ bounds: Sequence[TableBounds],
183
+ page_count: int,
184
+ current_dimensions: PageDimensions,
185
+ ) -> PageDimensions | None:
186
+ """Determine if re-layout is needed and return the expanded dimensions.
187
+
188
+ Args:
189
+ bounds: Measured bounds for table instances across pages.
190
+ page_count: Number of pages generated in the layout pass.
191
+ current_dimensions: Current page dimensions.
192
+
193
+ Returns:
194
+ Expanded PageDimensions if relayout is needed and dimensions change,
195
+ or ``None`` if current dimensions are sufficient.
196
+ """
197
+ if not bounds:
198
+ return None
199
+
200
+ max_right_css_px = max(b.x + b.width for b in bounds)
201
+ total_height_css_px = sum(b.height for b in bounds)
202
+
203
+ needed_width_css_px = math.ceil(
204
+ max_right_css_px + current_dimensions.body_padding_css_px
205
+ )
206
+ needed_height_css_px = math.ceil(
207
+ total_height_css_px
208
+ + current_dimensions.body_padding_css_px * 2
209
+ + HEIGHT_SAFETY_BUFFER_CSS_PX
210
+ )
211
+
212
+ target_width = max(current_dimensions.width, needed_width_css_px)
213
+ target_height = max(current_dimensions.height, needed_height_css_px)
214
+
215
+ if (
216
+ page_count > 1
217
+ or target_width > current_dimensions.width
218
+ or target_height > current_dimensions.height
219
+ ):
220
+ if page_count > 1 and target_height <= current_dimensions.height:
221
+ target_height = current_dimensions.height + HEIGHT_SAFETY_BUFFER_CSS_PX
222
+
223
+ return PageDimensions(
224
+ width=target_width,
225
+ height=target_height,
226
+ body_padding_css_px=current_dimensions.body_padding_css_px,
227
+ )
228
+
229
+ return None
230
+
231
+
232
+ def compute_crop_box_px(
233
+ bounds: TableBounds, *, dpi: int, padding: int
234
+ ) -> tuple[int, int, int, int]:
235
+ """Compute device-pixel crop bounding box from TableBounds.
236
+
237
+ Args:
238
+ bounds: Measured TableBounds in CSS pixels.
239
+ dpi: Target resolution in dots per inch.
240
+ padding: Padding around the table in device pixels.
241
+
242
+ Returns:
243
+ Tuple of ``(left, top, right, bottom)`` in device pixels.
244
+ """
245
+ device_px_per_css_px = dpi / CSS_DPI
246
+ pad = max(0, padding)
247
+
248
+ table_left_px = math.floor(bounds.x * device_px_per_css_px)
249
+ table_top_px = math.floor(bounds.y * device_px_per_css_px)
250
+
251
+ # Compute absolute right/bottom edges with outward slack for anti-aliased borders
252
+ table_right_px = (
253
+ math.ceil((bounds.x + bounds.width) * device_px_per_css_px)
254
+ + BORDER_SLACK_DEV_PX
255
+ )
256
+ table_bottom_px = (
257
+ math.ceil((bounds.y + bounds.height) * device_px_per_css_px)
258
+ + BORDER_SLACK_DEV_PX
259
+ )
260
+
261
+ left = max(0, table_left_px - pad)
262
+ top = max(0, table_top_px - pad)
263
+ right = table_right_px + pad
264
+ bottom = table_bottom_px + pad
265
+ return left, top, right, bottom
266
+
267
+
268
+ def compute_bbox_padding_px(
269
+ bbox: tuple[int, int, int, int], *, padding: int
270
+ ) -> tuple[int, int, int, int]:
271
+ """Apply device-pixel padding around a device-pixel bounding box.
272
+
273
+ Args:
274
+ bbox: Raw bounding box ``(left, top, right, bottom)`` in device pixels.
275
+ padding: Padding around content in device pixels.
276
+
277
+ Returns:
278
+ Tuple of ``(left, top, right, bottom)`` with padding applied.
279
+ """
280
+ pad = max(0, padding)
281
+ min_x, min_y, max_x, max_y = bbox
282
+ return (
283
+ max(0, min_x - pad),
284
+ max(0, min_y - pad),
285
+ max_x + pad,
286
+ max_y + pad,
287
+ )