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.
- {table_renderer-0.4.0 → table_renderer-0.6.0}/PKG-INFO +8 -5
- {table_renderer-0.4.0 → table_renderer-0.6.0}/README.md +6 -4
- table_renderer-0.6.0/pyproject.toml +105 -0
- table_renderer-0.4.0/pyproject.toml → table_renderer-0.6.0/pyproject.toml.orig +7 -4
- table_renderer-0.6.0/src/table_renderer/__init__.py +33 -0
- table_renderer-0.6.0/src/table_renderer/backends/__init__.py +6 -0
- table_renderer-0.6.0/src/table_renderer/backends/base.py +44 -0
- table_renderer-0.6.0/src/table_renderer/backends/geometry.py +287 -0
- table_renderer-0.6.0/src/table_renderer/backends/pdf_rasterizer.py +235 -0
- table_renderer-0.6.0/src/table_renderer/backends/weasyprint_renderer.py +206 -0
- table_renderer-0.6.0/src/table_renderer/html_generator.py +125 -0
- table_renderer-0.6.0/src/table_renderer/models/cell.py +146 -0
- table_renderer-0.6.0/src/table_renderer/models/row.py +31 -0
- {table_renderer-0.4.0 → table_renderer-0.6.0}/src/table_renderer/models/style.py +62 -28
- {table_renderer-0.4.0 → table_renderer-0.6.0}/src/table_renderer/models/table.py +36 -54
- table_renderer-0.6.0/src/table_renderer/renderer.py +122 -0
- table_renderer-0.6.0/src/table_renderer/resolved_table.py +172 -0
- table_renderer-0.6.0/src/table_renderer/templates/table.html.j2 +69 -0
- table_renderer-0.4.0/src/table_renderer/__init__.py +0 -11
- table_renderer-0.4.0/src/table_renderer/models/cell.py +0 -72
- 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.6.0}/src/table_renderer/models/__init__.py +0 -0
- {table_renderer-0.4.0 → table_renderer-0.6.0}/src/table_renderer/models/column.py +0 -0
- {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.
|
|
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").
|
|
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:**
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
40
|
-
"ty>=0.0.
|
|
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,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
|
+
)
|