pl2html 0.1.0__tar.gz → 0.2.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.
- pl2html-0.2.0/PKG-INFO +207 -0
- pl2html-0.2.0/README.md +198 -0
- {pl2html-0.1.0 → pl2html-0.2.0}/pl2html/__init__.py +46 -56
- pl2html-0.2.0/pl2html/formats.py +475 -0
- pl2html-0.2.0/pl2html/styles.py +179 -0
- {pl2html-0.1.0 → pl2html-0.2.0}/pyproject.toml +1 -1
- pl2html-0.1.0/PKG-INFO +0 -9
- pl2html-0.1.0/README.md +0 -0
pl2html-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pl2html
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Dist: polars>=1.42.1
|
|
6
|
+
Requires-Python: >=3.14
|
|
7
|
+
Project-URL: GitHub, https://github.com/5j9/pl2html
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# pl2html
|
|
11
|
+
|
|
12
|
+
A fast, secure, and zero-dependency HTML engine for Polars.
|
|
13
|
+
|
|
14
|
+
`pl2html` compiles Polars `DataFrame` and `LazyFrame` workflows natively into HTML table structures entirely inside the Polars expression engine. By leveraging parallelized Rust memory environments, it bypasses slow Python row loops (`.map_elements`), making it an incredibly lightweight alternative to styling engines like `great_tables` or pandas `.style`.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
* 🚀 **Native Polars Performance**: Runs entirely inside the Polars expression graph. Streamable and lazy-execution friendly.
|
|
19
|
+
* 🛡️ **Injection-Safe by Default**: Automatically neutralizes sequential HTML injection vectors using a lookahead-free, native escaping algorithm.
|
|
20
|
+
* 🎨 **Advanced Formatting Toolkit**: Human-readable display rules for numbers, integers, currencies, percentages, scientific notations, bytes, and booleans running entirely in Rust.
|
|
21
|
+
* ➡️ **Multicolumn Support**: All formatting and substitution functions accept a single column string or an iterable list of columns automatically.
|
|
22
|
+
* 🌗 **Conditional Styling & Color Scales**: Native linear interpolation pipelines to generate multi-segment background color heatmaps by value or percentile rank.
|
|
23
|
+
* 🌌 **Auto-Contrast Accessibility**: Dynamic text foreground switches (`#000000` vs `#FFFFFF`) calculated via WCAG relative luminance algorithms on the fly.
|
|
24
|
+
* 📦 **Afraid of Bloat? Zero Dependencies**: Cleaned of heavy packages like `numpy` or `matplotlib`. It runs entirely on pure Python and Polars.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install pl2html
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
### 1. Basic HTML Rendering with Auto-Escaping
|
|
39
|
+
By default, `pl2html` infers datatypes to securely render human-readable tables. Strings are escaped, and integers/floats automatically get localized thousands separators.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import polars as pl
|
|
43
|
+
from pl2html import to_html
|
|
44
|
+
|
|
45
|
+
df = pl.DataFrame(
|
|
46
|
+
{
|
|
47
|
+
'company': ["<script>alert('malicious')</script> Corp", 'Acme Inc.'],
|
|
48
|
+
'revenue': [1420500, -50200],
|
|
49
|
+
'margin': [0.0451, -0.0512],
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Compiles safely to a Polars LazyFrame containing the HTML output
|
|
54
|
+
html_lazy = to_html(df)
|
|
55
|
+
|
|
56
|
+
# Collect and extract the compiled string
|
|
57
|
+
html_table_string = html_lazy.collect().item()
|
|
58
|
+
print(html_table_string)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Output:
|
|
62
|
+
|
|
63
|
+
<table>
|
|
64
|
+
<thead>
|
|
65
|
+
<tr>
|
|
66
|
+
<th>company</th>
|
|
67
|
+
<th>revenue</th>
|
|
68
|
+
<th>margin</th>
|
|
69
|
+
</tr>
|
|
70
|
+
</thead>
|
|
71
|
+
<tbody>
|
|
72
|
+
<tr><td><script>alert('malicious')</script> Corp</td><td>1,420,500</td><td>0.045</td></tr>
|
|
73
|
+
<tr><td>Acme Inc.</td><td>-50,200</td><td>-0.051</td></tr>
|
|
74
|
+
</tbody>
|
|
75
|
+
</table>
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
### 2. Rich Data Formatting (Natively Vectorized)
|
|
79
|
+
`pl2html` exposes high-performance formatting expressions designed to prepare columns simultaneously before passing them to `to_html`. Every function natively supports passing lists of multiple columns at once.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import polars as pl
|
|
83
|
+
from pl2html import to_html
|
|
84
|
+
from pl2html import formats as fmt
|
|
85
|
+
|
|
86
|
+
df = pl.DataFrame(
|
|
87
|
+
{
|
|
88
|
+
"asset_a": [5400600, 2100],
|
|
89
|
+
"asset_b": [45100000, -850000],
|
|
90
|
+
"growth": [0.1256, -0.024],
|
|
91
|
+
"is_active": [True, False],
|
|
92
|
+
"status_code": [0, 404]
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Format multiple financial asset columns compactly in one pass using lists
|
|
97
|
+
df_f = df.with_columns(
|
|
98
|
+
fmt.fmt_integer(["asset_a", "asset_b"], compact=True, compact_system="financial"),
|
|
99
|
+
fmt.fmt_percent("growth", decimals=1),
|
|
100
|
+
fmt.fmt_tf("is_active", tf_style="check-mark"),
|
|
101
|
+
fmt.sub_zero("status_code", zero_text="OK")
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
html = to_html(df_f).collect().item()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 3. Advanced Styling via Native Attributes (`attrs`)
|
|
108
|
+
Instead of embedding hacky structural HTML tags in your data, use the `attrs` parameter to inject dynamic CSS attributes purely. Use `data_color` or `rank_color` to construct high-performance heatmaps.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
import polars as pl
|
|
112
|
+
from pl2html import to_html
|
|
113
|
+
from pl2html.styles import data_color, rank_color
|
|
114
|
+
|
|
115
|
+
df = pl.DataFrame(
|
|
116
|
+
{
|
|
117
|
+
'employee': ['Alice', 'Bob', 'Charlie', 'David'],
|
|
118
|
+
'performance_score': [98.5, 42.0, 81.2, 12.0],
|
|
119
|
+
'utilization': [0.02, 0.41, 0.78, 0.22],
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
styles = {}
|
|
124
|
+
|
|
125
|
+
# 1. Color Heatmap by Absolute Value (Custom 3-color palette with auto-contrast text)
|
|
126
|
+
styles.update(
|
|
127
|
+
data_color(
|
|
128
|
+
column='performance_score',
|
|
129
|
+
palette=['#ff7675', '#fdcb6e', '#00b894'],
|
|
130
|
+
auto_contrast=True
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# 2. Color Heatmap by Percentile Rank Order (Continuous interpolation)
|
|
135
|
+
styles.update(
|
|
136
|
+
rank_color(
|
|
137
|
+
column='utilization',
|
|
138
|
+
palette=['#000000', '#FFFFFF'],
|
|
139
|
+
descending=False,
|
|
140
|
+
auto_contrast=True
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
html_table = to_html(df, attrs=styles).collect().item()
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Output (github may override table colors, but they are there):
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
<table>
|
|
151
|
+
<thead>
|
|
152
|
+
<tr>
|
|
153
|
+
<th>employee</th>
|
|
154
|
+
<th>performance_score</th>
|
|
155
|
+
<th>utilization</th>
|
|
156
|
+
</tr>
|
|
157
|
+
</thead>
|
|
158
|
+
<tbody>
|
|
159
|
+
<tr><td>Alice</td><td style="background-color: rgb(0,184,148); color: #000000;">98.5</td><td style="background-color: rgb(255,255,255); color: #000000;">0.92</td></tr>
|
|
160
|
+
<tr><td>Bob</td><td style="background-color: rgb(254,177,112); color: #000000;">42.0</td><td style="background-color: rgb(85,85,85); color: #FFFFFF;">0.41</td></tr>
|
|
161
|
+
<tr><td>Charlie</td><td style="background-color: rgb(101,192,133); color: #000000;">81.2</td><td style="background-color: rgb(170,170,170); color: #000000;">0.78</td></tr>
|
|
162
|
+
<tr><td>David</td><td style="background-color: rgb(255,118,117); color: #000000;">12.0</td><td style="background-color: rgb(0,0,0); color: #FFFFFF;">0.22</td></tr>
|
|
163
|
+
</tbody>
|
|
164
|
+
</table>
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Architecture & Module Layout
|
|
169
|
+
|
|
170
|
+
The library is explicitly divided into three decoupled layers:
|
|
171
|
+
|
|
172
|
+
### 1. Core Compiler (`__init__.py`)
|
|
173
|
+
Responsible for reading the dataframe schema, iterating horizontally over visible columns, and joining tokens into structural row arrays natively in Rust.
|
|
174
|
+
* Exposed via `to_html(df, *, attrs=None, exclude_columns=None)`.
|
|
175
|
+
* Automatically assigns high-performance format fallbacks:
|
|
176
|
+
* `is_integer()`: Applies thousands separator reversals.
|
|
177
|
+
* `is_float()`: Automatically base-truncates decimal positions and breaks layout chunks smoothly.
|
|
178
|
+
* *Catchall*: Direct pass through `_escape_polars_string()` to secure against cross-site scripting (XSS).
|
|
179
|
+
|
|
180
|
+
### 2. Format Expressions (`formats.py`)
|
|
181
|
+
Contains native string-shaping expression generators wrapped in a `@_multicolumn` macro to cleanly display large datasets without dropping into slow Python loops.
|
|
182
|
+
* `fmt_number()` / `fmt_integer()`: Supports precision rounding, thousands separators, accounting parentheses, and financial/engineering compact suffixing (`K`, `M`, `B` vs `k`, `M`, `G`).
|
|
183
|
+
* `fmt_percent()` / `fmt_currency()`: Optimized wrappers handling percentage scaling and currency masking.
|
|
184
|
+
* `fmt_scientific()`: Natively parses mantissas and exponents with flexible styling (`x10n`, `e`, `E`) and forced signs.
|
|
185
|
+
* `fmt_bytes()`: Dynamically maps byte magnitudes to decimal (`kB`, `MB`) or binary (`KiB`, `MiB`) notations.
|
|
186
|
+
* `fmt_tf()`: Transforms booleans to presets (`arrows`, `check-mark`, `circles`) with native `na_val` null overrides.
|
|
187
|
+
* `sub_missing()` / `sub_zero()`: Clean conditional data substitution expressions for nulls and exact zeros.
|
|
188
|
+
|
|
189
|
+
### 3. Style Utilities (`styles.py`)
|
|
190
|
+
Generates structural Polars expression payloads mapped to specific HTML style attributes.
|
|
191
|
+
* `data_color(column, palette, domain=None, auto_contrast=True)`: Evaluates column boundaries (`.min()` / `.max()`) at evaluation time and maps row values via piece-wise linear RGB interpolation.
|
|
192
|
+
* `rank_color(column, palette, descending=False, auto_contrast=True)`: Replaces absolute numerical scaling with ordinal percentile ranks to smooth out heavily skewed datasets or extreme outliers.
|
|
193
|
+
|
|
194
|
+
**Every style output returns a clean nested dictionary structure (`{column: {attribute: expression}}`) to feed directly into `to_html(attrs=...)`.**
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Security
|
|
199
|
+
|
|
200
|
+
`pl2html` passes untrusted data arrays through a strict sequential escaping chain natively in Rust:
|
|
201
|
+
```python
|
|
202
|
+
.str.replace_all('&', '&')
|
|
203
|
+
.str.replace_all('<', '<')
|
|
204
|
+
.str.replace_all('>', '>')
|
|
205
|
+
.str.replace_all('"', '"')
|
|
206
|
+
.str.replace_all("'", ''')
|
|
207
|
+
```
|
pl2html-0.2.0/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# pl2html
|
|
2
|
+
|
|
3
|
+
A fast, secure, and zero-dependency HTML engine for Polars.
|
|
4
|
+
|
|
5
|
+
`pl2html` compiles Polars `DataFrame` and `LazyFrame` workflows natively into HTML table structures entirely inside the Polars expression engine. By leveraging parallelized Rust memory environments, it bypasses slow Python row loops (`.map_elements`), making it an incredibly lightweight alternative to styling engines like `great_tables` or pandas `.style`.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
* 🚀 **Native Polars Performance**: Runs entirely inside the Polars expression graph. Streamable and lazy-execution friendly.
|
|
10
|
+
* 🛡️ **Injection-Safe by Default**: Automatically neutralizes sequential HTML injection vectors using a lookahead-free, native escaping algorithm.
|
|
11
|
+
* 🎨 **Advanced Formatting Toolkit**: Human-readable display rules for numbers, integers, currencies, percentages, scientific notations, bytes, and booleans running entirely in Rust.
|
|
12
|
+
* ➡️ **Multicolumn Support**: All formatting and substitution functions accept a single column string or an iterable list of columns automatically.
|
|
13
|
+
* 🌗 **Conditional Styling & Color Scales**: Native linear interpolation pipelines to generate multi-segment background color heatmaps by value or percentile rank.
|
|
14
|
+
* 🌌 **Auto-Contrast Accessibility**: Dynamic text foreground switches (`#000000` vs `#FFFFFF`) calculated via WCAG relative luminance algorithms on the fly.
|
|
15
|
+
* 📦 **Afraid of Bloat? Zero Dependencies**: Cleaned of heavy packages like `numpy` or `matplotlib`. It runs entirely on pure Python and Polars.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install pl2html
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
### 1. Basic HTML Rendering with Auto-Escaping
|
|
30
|
+
By default, `pl2html` infers datatypes to securely render human-readable tables. Strings are escaped, and integers/floats automatically get localized thousands separators.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import polars as pl
|
|
34
|
+
from pl2html import to_html
|
|
35
|
+
|
|
36
|
+
df = pl.DataFrame(
|
|
37
|
+
{
|
|
38
|
+
'company': ["<script>alert('malicious')</script> Corp", 'Acme Inc.'],
|
|
39
|
+
'revenue': [1420500, -50200],
|
|
40
|
+
'margin': [0.0451, -0.0512],
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Compiles safely to a Polars LazyFrame containing the HTML output
|
|
45
|
+
html_lazy = to_html(df)
|
|
46
|
+
|
|
47
|
+
# Collect and extract the compiled string
|
|
48
|
+
html_table_string = html_lazy.collect().item()
|
|
49
|
+
print(html_table_string)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Output:
|
|
53
|
+
|
|
54
|
+
<table>
|
|
55
|
+
<thead>
|
|
56
|
+
<tr>
|
|
57
|
+
<th>company</th>
|
|
58
|
+
<th>revenue</th>
|
|
59
|
+
<th>margin</th>
|
|
60
|
+
</tr>
|
|
61
|
+
</thead>
|
|
62
|
+
<tbody>
|
|
63
|
+
<tr><td><script>alert('malicious')</script> Corp</td><td>1,420,500</td><td>0.045</td></tr>
|
|
64
|
+
<tr><td>Acme Inc.</td><td>-50,200</td><td>-0.051</td></tr>
|
|
65
|
+
</tbody>
|
|
66
|
+
</table>
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
### 2. Rich Data Formatting (Natively Vectorized)
|
|
70
|
+
`pl2html` exposes high-performance formatting expressions designed to prepare columns simultaneously before passing them to `to_html`. Every function natively supports passing lists of multiple columns at once.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import polars as pl
|
|
74
|
+
from pl2html import to_html
|
|
75
|
+
from pl2html import formats as fmt
|
|
76
|
+
|
|
77
|
+
df = pl.DataFrame(
|
|
78
|
+
{
|
|
79
|
+
"asset_a": [5400600, 2100],
|
|
80
|
+
"asset_b": [45100000, -850000],
|
|
81
|
+
"growth": [0.1256, -0.024],
|
|
82
|
+
"is_active": [True, False],
|
|
83
|
+
"status_code": [0, 404]
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Format multiple financial asset columns compactly in one pass using lists
|
|
88
|
+
df_f = df.with_columns(
|
|
89
|
+
fmt.fmt_integer(["asset_a", "asset_b"], compact=True, compact_system="financial"),
|
|
90
|
+
fmt.fmt_percent("growth", decimals=1),
|
|
91
|
+
fmt.fmt_tf("is_active", tf_style="check-mark"),
|
|
92
|
+
fmt.sub_zero("status_code", zero_text="OK")
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
html = to_html(df_f).collect().item()
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 3. Advanced Styling via Native Attributes (`attrs`)
|
|
99
|
+
Instead of embedding hacky structural HTML tags in your data, use the `attrs` parameter to inject dynamic CSS attributes purely. Use `data_color` or `rank_color` to construct high-performance heatmaps.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import polars as pl
|
|
103
|
+
from pl2html import to_html
|
|
104
|
+
from pl2html.styles import data_color, rank_color
|
|
105
|
+
|
|
106
|
+
df = pl.DataFrame(
|
|
107
|
+
{
|
|
108
|
+
'employee': ['Alice', 'Bob', 'Charlie', 'David'],
|
|
109
|
+
'performance_score': [98.5, 42.0, 81.2, 12.0],
|
|
110
|
+
'utilization': [0.02, 0.41, 0.78, 0.22],
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
styles = {}
|
|
115
|
+
|
|
116
|
+
# 1. Color Heatmap by Absolute Value (Custom 3-color palette with auto-contrast text)
|
|
117
|
+
styles.update(
|
|
118
|
+
data_color(
|
|
119
|
+
column='performance_score',
|
|
120
|
+
palette=['#ff7675', '#fdcb6e', '#00b894'],
|
|
121
|
+
auto_contrast=True
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# 2. Color Heatmap by Percentile Rank Order (Continuous interpolation)
|
|
126
|
+
styles.update(
|
|
127
|
+
rank_color(
|
|
128
|
+
column='utilization',
|
|
129
|
+
palette=['#000000', '#FFFFFF'],
|
|
130
|
+
descending=False,
|
|
131
|
+
auto_contrast=True
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
html_table = to_html(df, attrs=styles).collect().item()
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Output (github may override table colors, but they are there):
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
<table>
|
|
142
|
+
<thead>
|
|
143
|
+
<tr>
|
|
144
|
+
<th>employee</th>
|
|
145
|
+
<th>performance_score</th>
|
|
146
|
+
<th>utilization</th>
|
|
147
|
+
</tr>
|
|
148
|
+
</thead>
|
|
149
|
+
<tbody>
|
|
150
|
+
<tr><td>Alice</td><td style="background-color: rgb(0,184,148); color: #000000;">98.5</td><td style="background-color: rgb(255,255,255); color: #000000;">0.92</td></tr>
|
|
151
|
+
<tr><td>Bob</td><td style="background-color: rgb(254,177,112); color: #000000;">42.0</td><td style="background-color: rgb(85,85,85); color: #FFFFFF;">0.41</td></tr>
|
|
152
|
+
<tr><td>Charlie</td><td style="background-color: rgb(101,192,133); color: #000000;">81.2</td><td style="background-color: rgb(170,170,170); color: #000000;">0.78</td></tr>
|
|
153
|
+
<tr><td>David</td><td style="background-color: rgb(255,118,117); color: #000000;">12.0</td><td style="background-color: rgb(0,0,0); color: #FFFFFF;">0.22</td></tr>
|
|
154
|
+
</tbody>
|
|
155
|
+
</table>
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Architecture & Module Layout
|
|
160
|
+
|
|
161
|
+
The library is explicitly divided into three decoupled layers:
|
|
162
|
+
|
|
163
|
+
### 1. Core Compiler (`__init__.py`)
|
|
164
|
+
Responsible for reading the dataframe schema, iterating horizontally over visible columns, and joining tokens into structural row arrays natively in Rust.
|
|
165
|
+
* Exposed via `to_html(df, *, attrs=None, exclude_columns=None)`.
|
|
166
|
+
* Automatically assigns high-performance format fallbacks:
|
|
167
|
+
* `is_integer()`: Applies thousands separator reversals.
|
|
168
|
+
* `is_float()`: Automatically base-truncates decimal positions and breaks layout chunks smoothly.
|
|
169
|
+
* *Catchall*: Direct pass through `_escape_polars_string()` to secure against cross-site scripting (XSS).
|
|
170
|
+
|
|
171
|
+
### 2. Format Expressions (`formats.py`)
|
|
172
|
+
Contains native string-shaping expression generators wrapped in a `@_multicolumn` macro to cleanly display large datasets without dropping into slow Python loops.
|
|
173
|
+
* `fmt_number()` / `fmt_integer()`: Supports precision rounding, thousands separators, accounting parentheses, and financial/engineering compact suffixing (`K`, `M`, `B` vs `k`, `M`, `G`).
|
|
174
|
+
* `fmt_percent()` / `fmt_currency()`: Optimized wrappers handling percentage scaling and currency masking.
|
|
175
|
+
* `fmt_scientific()`: Natively parses mantissas and exponents with flexible styling (`x10n`, `e`, `E`) and forced signs.
|
|
176
|
+
* `fmt_bytes()`: Dynamically maps byte magnitudes to decimal (`kB`, `MB`) or binary (`KiB`, `MiB`) notations.
|
|
177
|
+
* `fmt_tf()`: Transforms booleans to presets (`arrows`, `check-mark`, `circles`) with native `na_val` null overrides.
|
|
178
|
+
* `sub_missing()` / `sub_zero()`: Clean conditional data substitution expressions for nulls and exact zeros.
|
|
179
|
+
|
|
180
|
+
### 3. Style Utilities (`styles.py`)
|
|
181
|
+
Generates structural Polars expression payloads mapped to specific HTML style attributes.
|
|
182
|
+
* `data_color(column, palette, domain=None, auto_contrast=True)`: Evaluates column boundaries (`.min()` / `.max()`) at evaluation time and maps row values via piece-wise linear RGB interpolation.
|
|
183
|
+
* `rank_color(column, palette, descending=False, auto_contrast=True)`: Replaces absolute numerical scaling with ordinal percentile ranks to smooth out heavily skewed datasets or extreme outliers.
|
|
184
|
+
|
|
185
|
+
**Every style output returns a clean nested dictionary structure (`{column: {attribute: expression}}`) to feed directly into `to_html(attrs=...)`.**
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Security
|
|
190
|
+
|
|
191
|
+
`pl2html` passes untrusted data arrays through a strict sequential escaping chain natively in Rust:
|
|
192
|
+
```python
|
|
193
|
+
.str.replace_all('&', '&')
|
|
194
|
+
.str.replace_all('<', '<')
|
|
195
|
+
.str.replace_all('>', '>')
|
|
196
|
+
.str.replace_all('"', '"')
|
|
197
|
+
.str.replace_all("'", ''')
|
|
198
|
+
```
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
from polars import (
|
|
2
|
+
DataFrame as _DataFrame,
|
|
3
|
+
DataType as _DataType,
|
|
4
|
+
Expr as _Expr,
|
|
5
|
+
LazyFrame as _LazyFrame,
|
|
6
|
+
String as _String,
|
|
7
|
+
col as _col,
|
|
8
|
+
concat_str as _concat_str,
|
|
9
|
+
lit as _lit,
|
|
10
|
+
when as _when,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__version__ = '0.2.0'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _escape_polars_string(col_name: str) -> _Expr:
|
|
7
17
|
"""
|
|
8
18
|
Escapes a column's string representations for secure HTML compatibility.
|
|
9
19
|
Because standard HTML entities use look-arounds or loops, we can fall back to
|
|
@@ -11,8 +21,8 @@ def _escape_polars_string(col_name: str) -> pl.Expr:
|
|
|
11
21
|
but basic safety replaces characters sequentially.
|
|
12
22
|
"""
|
|
13
23
|
return (
|
|
14
|
-
|
|
15
|
-
.cast(
|
|
24
|
+
_col(col_name)
|
|
25
|
+
.cast(_String)
|
|
16
26
|
.str.replace_all('&', '&')
|
|
17
27
|
.str.replace_all('<', '<')
|
|
18
28
|
.str.replace_all('>', '>')
|
|
@@ -21,12 +31,12 @@ def _escape_polars_string(col_name: str) -> pl.Expr:
|
|
|
21
31
|
)
|
|
22
32
|
|
|
23
33
|
|
|
24
|
-
def _format_integer(col_name: str) ->
|
|
34
|
+
def _format_integer(col_name: str) -> _Expr:
|
|
25
35
|
"""Adds thousands separators to integers using look-around-free logic."""
|
|
26
36
|
return (
|
|
27
|
-
|
|
37
|
+
_col(col_name)
|
|
28
38
|
.fill_null(0)
|
|
29
|
-
.cast(
|
|
39
|
+
.cast(_String)
|
|
30
40
|
.str.reverse()
|
|
31
41
|
.str.replace_all(r'\d{3}', '$0,')
|
|
32
42
|
.str.reverse()
|
|
@@ -35,9 +45,9 @@ def _format_integer(col_name: str) -> pl.Expr:
|
|
|
35
45
|
)
|
|
36
46
|
|
|
37
47
|
|
|
38
|
-
def _format_float(col_name: str) ->
|
|
48
|
+
def _format_float(col_name: str) -> _Expr:
|
|
39
49
|
"""Formats floats with 3 decimals and thousands separators."""
|
|
40
|
-
rounded_str =
|
|
50
|
+
rounded_str = _col(col_name).round(3).cast(_String)
|
|
41
51
|
int_part = rounded_str.str.split_exact('.', 1).struct.field('field_0')
|
|
42
52
|
dec_part = rounded_str.str.split_exact('.', 1).struct.field('field_1')
|
|
43
53
|
|
|
@@ -50,17 +60,17 @@ def _format_float(col_name: str) -> pl.Expr:
|
|
|
50
60
|
)
|
|
51
61
|
|
|
52
62
|
return (
|
|
53
|
-
|
|
54
|
-
.then(formatted_int +
|
|
63
|
+
_when(dec_part.is_not_null() & (dec_part != ''))
|
|
64
|
+
.then(formatted_int + _lit('.') + dec_part)
|
|
55
65
|
.otherwise(formatted_int)
|
|
56
66
|
)
|
|
57
67
|
|
|
58
68
|
|
|
59
69
|
def _build_cell_expr(
|
|
60
70
|
col_name: str,
|
|
61
|
-
dtype:
|
|
62
|
-
attrs: dict[str, dict[str,
|
|
63
|
-
) ->
|
|
71
|
+
dtype: _DataType,
|
|
72
|
+
attrs: dict[str, dict[str, _Expr]],
|
|
73
|
+
) -> _Expr:
|
|
64
74
|
"""
|
|
65
75
|
Applies secure auto-escaping or data formatting overrides, then dynamically
|
|
66
76
|
compiles HTML attributes into a valid single opening <td> tag block.
|
|
@@ -81,54 +91,47 @@ def _build_cell_expr(
|
|
|
81
91
|
for attr_name, val_expr in attrs[col_name].items():
|
|
82
92
|
# If the expression returns null or empty for a specific row, skip writing the attribute
|
|
83
93
|
compiled_attr = (
|
|
84
|
-
|
|
94
|
+
_when(val_expr.is_not_null() & (val_expr != ''))
|
|
85
95
|
.then(
|
|
86
|
-
|
|
87
|
-
+ val_expr.cast(
|
|
88
|
-
+
|
|
96
|
+
_lit(f' {attr_name}="')
|
|
97
|
+
+ val_expr.cast(_String)
|
|
98
|
+
+ _lit('"')
|
|
89
99
|
)
|
|
90
|
-
.otherwise(
|
|
100
|
+
.otherwise(_lit(''))
|
|
91
101
|
)
|
|
92
102
|
attr_expr_list.append(compiled_attr)
|
|
93
103
|
|
|
94
104
|
# Combine attribute strings together
|
|
95
105
|
if attr_expr_list:
|
|
96
|
-
opening_td = (
|
|
97
|
-
pl.lit('<td') + pl.concat_str(attr_expr_list) + pl.lit('>')
|
|
98
|
-
)
|
|
106
|
+
opening_td = _lit('<td') + _concat_str(attr_expr_list) + _lit('>')
|
|
99
107
|
else:
|
|
100
|
-
opening_td =
|
|
108
|
+
opening_td = _lit('<td>')
|
|
101
109
|
|
|
102
|
-
return opening_td + fmt_expr +
|
|
110
|
+
return opening_td + fmt_expr + _lit('</td>')
|
|
103
111
|
|
|
104
112
|
|
|
105
|
-
def _build_html_skeleton(
|
|
106
|
-
visible_columns: list[str], add_row_no: bool
|
|
107
|
-
) -> tuple[pl.Expr, pl.Expr]:
|
|
113
|
+
def _build_html_skeleton(visible_columns: list[str]) -> tuple[_Expr, _Expr]:
|
|
108
114
|
header_parts = ['<table>', ' <thead>\n <tr>']
|
|
109
|
-
if add_row_no:
|
|
110
|
-
header_parts.append(' <th>#</th>')
|
|
111
115
|
for c in visible_columns:
|
|
112
116
|
header_parts.append(f' <th>{c}</th>')
|
|
113
117
|
header_parts.append(' </tr>\n </thead>\n <tbody>')
|
|
114
118
|
|
|
115
|
-
html_header =
|
|
116
|
-
html_footer =
|
|
119
|
+
html_header = _lit('\n'.join(header_parts) + '\n')
|
|
120
|
+
html_footer = _lit('\n </tbody>\n</table>')
|
|
117
121
|
return html_header, html_footer
|
|
118
122
|
|
|
119
123
|
|
|
120
124
|
def to_html(
|
|
121
|
-
df:
|
|
125
|
+
df: _DataFrame | _LazyFrame,
|
|
122
126
|
*,
|
|
123
|
-
attrs: dict[str, dict[str,
|
|
127
|
+
attrs: dict[str, dict[str, _Expr]] | None = None,
|
|
124
128
|
exclude_columns: list[str] | None = None,
|
|
125
|
-
|
|
126
|
-
) -> pl.LazyFrame:
|
|
129
|
+
) -> _LazyFrame:
|
|
127
130
|
"""
|
|
128
131
|
Compiles a Polars DataFrame safely into an HTML string layout.
|
|
129
132
|
Accepts structural custom attributes mappings to handle layout modifications natively.
|
|
130
133
|
"""
|
|
131
|
-
lf = df.lazy() if isinstance(df,
|
|
134
|
+
lf = df.lazy() if isinstance(df, _DataFrame) else df
|
|
132
135
|
|
|
133
136
|
exclude_columns = exclude_columns or []
|
|
134
137
|
attrs = attrs or {}
|
|
@@ -138,31 +141,18 @@ def to_html(
|
|
|
138
141
|
|
|
139
142
|
cell_expressions = []
|
|
140
143
|
|
|
141
|
-
# 1. Row Index Element Processing
|
|
142
|
-
if add_row_no:
|
|
143
|
-
index_expr = (
|
|
144
|
-
pl.lit('<td>')
|
|
145
|
-
+ (pl.arange(1, pl.len() + 1).cast(pl.String))
|
|
146
|
-
+ pl.lit('</td>')
|
|
147
|
-
)
|
|
148
|
-
cell_expressions.append(index_expr)
|
|
149
|
-
|
|
150
144
|
# 2. Map structural cell loops
|
|
151
145
|
for c in visible_columns:
|
|
152
146
|
cell_expressions.append(_build_cell_expr(c, schema[c], attrs))
|
|
153
147
|
|
|
154
148
|
# 3. Concatenate columns horizontally into rows
|
|
155
|
-
row_expr = (
|
|
156
|
-
pl.lit(' <tr>') + pl.concat_str(cell_expressions) + pl.lit('</tr>')
|
|
157
|
-
)
|
|
149
|
+
row_expr = _lit(' <tr>') + _concat_str(cell_expressions) + _lit('</tr>')
|
|
158
150
|
|
|
159
151
|
# 4. Generate wrappers and frame the query graph
|
|
160
|
-
html_header, html_footer = _build_html_skeleton(
|
|
161
|
-
visible_columns, add_row_no
|
|
162
|
-
)
|
|
152
|
+
html_header, html_footer = _build_html_skeleton(visible_columns)
|
|
163
153
|
|
|
164
154
|
return lf.select(row_expr.alias('html_row')).select(
|
|
165
|
-
(html_header +
|
|
155
|
+
(html_header + _col('html_row').str.join('\n') + html_footer).alias(
|
|
166
156
|
'html_table'
|
|
167
157
|
)
|
|
168
158
|
)
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
from collections.abc import Iterable as _Iterable
|
|
2
|
+
from functools import wraps as _wraps
|
|
3
|
+
from typing import Literal as _Literal
|
|
4
|
+
|
|
5
|
+
from polars import (
|
|
6
|
+
Expr as _Expr,
|
|
7
|
+
Float64 as _Float64,
|
|
8
|
+
Int32 as _Int32,
|
|
9
|
+
Int64 as _Int64,
|
|
10
|
+
String as _String,
|
|
11
|
+
col as _col,
|
|
12
|
+
lit as _lit,
|
|
13
|
+
when as _when,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# --- 0. Decorator to cleanly support single or multiple columns ---
|
|
18
|
+
def _multicolumn(func):
|
|
19
|
+
@_wraps(func)
|
|
20
|
+
def wrapper(columns: str | _Iterable[str], *args, **kwargs):
|
|
21
|
+
if isinstance(columns, str):
|
|
22
|
+
return func(columns, *args, **kwargs)
|
|
23
|
+
# Automatically alias each expression to its original column name
|
|
24
|
+
# so Polars knows exactly which column to overwrite/create.
|
|
25
|
+
return [func(col, *args, **kwargs).alias(col) for col in columns]
|
|
26
|
+
|
|
27
|
+
return wrapper
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- 1. Formatter Implementation Block ---
|
|
31
|
+
|
|
32
|
+
_Columns = str | list[str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@_multicolumn
|
|
36
|
+
def fmt_number(
|
|
37
|
+
columns: _Columns,
|
|
38
|
+
decimals: int = 2,
|
|
39
|
+
scale_by: float = 1.0,
|
|
40
|
+
compact: bool = False,
|
|
41
|
+
compact_system: _Literal['financial', 'engineering'] = 'financial',
|
|
42
|
+
use_seps: bool = True,
|
|
43
|
+
accounting: bool = False,
|
|
44
|
+
pattern: str = '{x}',
|
|
45
|
+
) -> _Expr:
|
|
46
|
+
"""
|
|
47
|
+
Highly optimized, native Polars numeric formatter matching great_tables features.
|
|
48
|
+
Runs entirely in the parallel Rust engine without dropping into Python row loops.
|
|
49
|
+
"""
|
|
50
|
+
val = _col(columns)
|
|
51
|
+
if scale_by != 1.0:
|
|
52
|
+
val = val * scale_by
|
|
53
|
+
|
|
54
|
+
abs_val = val.abs()
|
|
55
|
+
|
|
56
|
+
# 1. Extract compact scaling suffix chain if enabled
|
|
57
|
+
if compact:
|
|
58
|
+
log10_expr = (
|
|
59
|
+
_when(abs_val > 0).then(abs_val.log10()).otherwise(_lit(0.0))
|
|
60
|
+
)
|
|
61
|
+
thousands_exponent = (log10_expr / 3.0).floor().cast(_Int32) * 3
|
|
62
|
+
|
|
63
|
+
if compact_system == 'engineering':
|
|
64
|
+
suffix_chain = (
|
|
65
|
+
_when(thousands_exponent == 3)
|
|
66
|
+
.then(_lit('k'))
|
|
67
|
+
.when(thousands_exponent == 6)
|
|
68
|
+
.then(_lit('M'))
|
|
69
|
+
.when(thousands_exponent == 9)
|
|
70
|
+
.then(_lit('G'))
|
|
71
|
+
.when(thousands_exponent == 12)
|
|
72
|
+
.then(_lit('T'))
|
|
73
|
+
.otherwise(_lit(''))
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
suffix_chain = (
|
|
77
|
+
_when(thousands_exponent == 3)
|
|
78
|
+
.then(_lit('K'))
|
|
79
|
+
.when(thousands_exponent == 6)
|
|
80
|
+
.then(_lit('M'))
|
|
81
|
+
.when(thousands_exponent == 9)
|
|
82
|
+
.then(_lit('B'))
|
|
83
|
+
.when(thousands_exponent == 12)
|
|
84
|
+
.then(_lit('T'))
|
|
85
|
+
.otherwise(_lit(''))
|
|
86
|
+
)
|
|
87
|
+
divisor = _lit(10.0).pow(thousands_exponent.cast(_Float64))
|
|
88
|
+
val_scaled = val / divisor
|
|
89
|
+
else:
|
|
90
|
+
val_scaled = val
|
|
91
|
+
suffix_chain = _lit('')
|
|
92
|
+
|
|
93
|
+
# 2. Handle precision rounding and extract base components natively
|
|
94
|
+
epsilon = _when(val_scaled >= 0).then(_lit(1e-9)).otherwise(_lit(-1e-9))
|
|
95
|
+
rounded = (val_scaled + epsilon).round(decimals)
|
|
96
|
+
|
|
97
|
+
if decimals > 0:
|
|
98
|
+
int_part = rounded.cast(_Int64).abs().cast(_String)
|
|
99
|
+
full_str = rounded.abs().cast(_String)
|
|
100
|
+
frac_part = (
|
|
101
|
+
_when(full_str.str.contains(r'\.'))
|
|
102
|
+
.then(full_str.str.split('.').list.get(1).str.slice(0, decimals))
|
|
103
|
+
.otherwise(_lit(''))
|
|
104
|
+
).str.pad_end(decimals, fill_char='0')
|
|
105
|
+
|
|
106
|
+
if use_seps:
|
|
107
|
+
int_part = (
|
|
108
|
+
int_part.str.reverse()
|
|
109
|
+
.str.replace_all(r'(\d{3})', r'${1},')
|
|
110
|
+
.str.strip_suffix(',')
|
|
111
|
+
.str.reverse()
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
base_num_str = int_part + _lit('.') + frac_part
|
|
115
|
+
else:
|
|
116
|
+
base_num_str = rounded.round(0).cast(_Int64).abs().cast(_String)
|
|
117
|
+
if use_seps:
|
|
118
|
+
base_num_str = (
|
|
119
|
+
base_num_str.str.reverse()
|
|
120
|
+
.str.replace_all(r'(\d{3})', r'${1},')
|
|
121
|
+
.str.strip_suffix(',')
|
|
122
|
+
.str.reverse()
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
base_num_str = base_num_str + suffix_chain
|
|
126
|
+
|
|
127
|
+
prefix, suffix = '', ''
|
|
128
|
+
if pattern != '{x}':
|
|
129
|
+
parts = pattern.split('{x}')
|
|
130
|
+
if len(parts) == 2:
|
|
131
|
+
prefix, suffix = parts[0], parts[1]
|
|
132
|
+
|
|
133
|
+
if accounting:
|
|
134
|
+
formatted_expr = (
|
|
135
|
+
_when(val < 0)
|
|
136
|
+
.then(
|
|
137
|
+
_lit('(')
|
|
138
|
+
+ _lit(prefix)
|
|
139
|
+
+ base_num_str
|
|
140
|
+
+ _lit(suffix)
|
|
141
|
+
+ _lit(')')
|
|
142
|
+
)
|
|
143
|
+
.otherwise(_lit(prefix) + base_num_str + _lit(suffix))
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
formatted_expr = (
|
|
147
|
+
_when(val < 0)
|
|
148
|
+
.then(_lit('-') + _lit(prefix) + base_num_str + _lit(suffix))
|
|
149
|
+
.otherwise(_lit(prefix) + base_num_str + _lit(suffix))
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return formatted_expr
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@_multicolumn
|
|
156
|
+
def fmt_percent(
|
|
157
|
+
columns: _Columns, decimals: int = 2, use_seps: bool = True
|
|
158
|
+
) -> _Expr:
|
|
159
|
+
"""Formats columns as percentages, multiplying by 100 automatically."""
|
|
160
|
+
return fmt_number(
|
|
161
|
+
columns=columns,
|
|
162
|
+
decimals=decimals,
|
|
163
|
+
scale_by=100.0,
|
|
164
|
+
use_seps=use_seps,
|
|
165
|
+
pattern='{x}%',
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@_multicolumn
|
|
170
|
+
def fmt_currency(
|
|
171
|
+
columns: _Columns,
|
|
172
|
+
symbol: str = '$',
|
|
173
|
+
decimals: int = 2,
|
|
174
|
+
use_seps: bool = True,
|
|
175
|
+
accounting: bool = True,
|
|
176
|
+
) -> _Expr:
|
|
177
|
+
"""Formats columns as localized currency values."""
|
|
178
|
+
return fmt_number(
|
|
179
|
+
columns=columns,
|
|
180
|
+
decimals=decimals,
|
|
181
|
+
use_seps=use_seps,
|
|
182
|
+
accounting=accounting,
|
|
183
|
+
pattern=f'{symbol}{{x}}',
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@_multicolumn
|
|
188
|
+
def fmt_scientific(
|
|
189
|
+
columns: _Columns,
|
|
190
|
+
decimals: int = 2,
|
|
191
|
+
scale_by: float = 1.0,
|
|
192
|
+
exp_style: _Literal['x10n', 'e', 'E'] = 'x10n',
|
|
193
|
+
pattern: str = '{x}',
|
|
194
|
+
force_sign_m: bool = False,
|
|
195
|
+
force_sign_n: bool = False,
|
|
196
|
+
) -> _Expr:
|
|
197
|
+
"""Highly optimized scientific notation formatter."""
|
|
198
|
+
val = _col(columns)
|
|
199
|
+
if scale_by != 1.0:
|
|
200
|
+
val = val * scale_by
|
|
201
|
+
|
|
202
|
+
abs_val = val.abs()
|
|
203
|
+
log10_expr = _when(abs_val > 0).then(abs_val.log10()).otherwise(_lit(0.0))
|
|
204
|
+
exponent = log10_expr.floor().cast(_Int32)
|
|
205
|
+
exponent = _when(val == 0).then(_lit(0)).otherwise(exponent)
|
|
206
|
+
|
|
207
|
+
divisor = _lit(10.0).pow(exponent.cast(_Float64))
|
|
208
|
+
mantissa = val / divisor
|
|
209
|
+
|
|
210
|
+
epsilon = _when(mantissa >= 0).then(_lit(1e-9)).otherwise(_lit(-1e-9))
|
|
211
|
+
rounded_m = (mantissa + epsilon).round(decimals)
|
|
212
|
+
|
|
213
|
+
if decimals > 0:
|
|
214
|
+
int_m = rounded_m.cast(_Int64).abs().cast(_String)
|
|
215
|
+
full_str_m = rounded_m.abs().cast(_String)
|
|
216
|
+
frac_m = (
|
|
217
|
+
_when(full_str_m.str.contains(r'\.'))
|
|
218
|
+
.then(full_str_m.str.split('.').list.get(1).str.slice(0, decimals))
|
|
219
|
+
.otherwise(_lit(''))
|
|
220
|
+
).str.pad_end(decimals, fill_char='0')
|
|
221
|
+
m_str = int_m + _lit('.') + frac_m
|
|
222
|
+
else:
|
|
223
|
+
m_str = rounded_m.round(0).cast(_Int64).abs().cast(_String)
|
|
224
|
+
|
|
225
|
+
m_str = (
|
|
226
|
+
_when(val < 0)
|
|
227
|
+
.then(_lit('-') + m_str)
|
|
228
|
+
.when((_lit(force_sign_m)) & (val > 0))
|
|
229
|
+
.then(_lit('+') + m_str)
|
|
230
|
+
.otherwise(m_str)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
n_str = exponent.abs().cast(_String)
|
|
234
|
+
n_str = (
|
|
235
|
+
_when(exponent < 0)
|
|
236
|
+
.then(_lit('-') + n_str)
|
|
237
|
+
.when((_lit(force_sign_n)) & (exponent > 0))
|
|
238
|
+
.then(_lit('+') + n_str)
|
|
239
|
+
.otherwise(
|
|
240
|
+
_when(_lit(force_sign_n)).then(_lit('+') + n_str).otherwise(n_str)
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if exp_style == 'x10n':
|
|
245
|
+
combined = m_str + _lit(' × 10^') + n_str
|
|
246
|
+
elif exp_style == 'E':
|
|
247
|
+
combined = m_str + _lit('E') + n_str
|
|
248
|
+
else:
|
|
249
|
+
combined = m_str + _lit('e') + n_str
|
|
250
|
+
|
|
251
|
+
prefix, suffix = '', ''
|
|
252
|
+
if pattern != '{x}':
|
|
253
|
+
parts = pattern.split('{x}')
|
|
254
|
+
if len(parts) == 2:
|
|
255
|
+
prefix, suffix = parts[0], parts[1]
|
|
256
|
+
|
|
257
|
+
return _lit(prefix) + combined + _lit(suffix)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@_multicolumn
|
|
261
|
+
def fmt_bytes(
|
|
262
|
+
columns: _Columns,
|
|
263
|
+
standard: _Literal['decimal', 'binary'] = 'decimal',
|
|
264
|
+
decimals: int = 1,
|
|
265
|
+
use_seps: bool = True,
|
|
266
|
+
pattern: str = '{x}',
|
|
267
|
+
force_sign: bool = False,
|
|
268
|
+
incl_space: bool = True,
|
|
269
|
+
) -> _Expr:
|
|
270
|
+
"""Highly optimized native bytes formatter."""
|
|
271
|
+
val = _col(columns)
|
|
272
|
+
abs_val = val.abs()
|
|
273
|
+
|
|
274
|
+
if standard == 'binary':
|
|
275
|
+
base = 1024.0
|
|
276
|
+
log_expr = (
|
|
277
|
+
_when(abs_val > 0).then(abs_val.log(2.0)).otherwise(_lit(0.0))
|
|
278
|
+
)
|
|
279
|
+
exponent_idx = (log_expr / 10.0).floor().cast(_Int32)
|
|
280
|
+
exponent_idx = (
|
|
281
|
+
_when(exponent_idx < 0).then(_lit(0)).otherwise(exponent_idx)
|
|
282
|
+
)
|
|
283
|
+
suffix_chain = (
|
|
284
|
+
_when(exponent_idx == 0)
|
|
285
|
+
.then(_lit('B'))
|
|
286
|
+
.when(exponent_idx == 1)
|
|
287
|
+
.then(_lit('KiB'))
|
|
288
|
+
.when(exponent_idx == 2)
|
|
289
|
+
.then(_lit('MiB'))
|
|
290
|
+
.when(exponent_idx == 3)
|
|
291
|
+
.then(_lit('GiB'))
|
|
292
|
+
.when(exponent_idx == 4)
|
|
293
|
+
.then(_lit('TiB'))
|
|
294
|
+
.when(exponent_idx == 5)
|
|
295
|
+
.then(_lit('PiB'))
|
|
296
|
+
.otherwise(_lit('B'))
|
|
297
|
+
)
|
|
298
|
+
else:
|
|
299
|
+
base = 1000.0
|
|
300
|
+
log_expr = (
|
|
301
|
+
_when(abs_val > 0).then(abs_val.log10()).otherwise(_lit(0.0))
|
|
302
|
+
)
|
|
303
|
+
exponent_idx = (log_expr / 3.0).floor().cast(_Int32)
|
|
304
|
+
exponent_idx = (
|
|
305
|
+
_when(exponent_idx < 0).then(_lit(0)).otherwise(exponent_idx)
|
|
306
|
+
)
|
|
307
|
+
suffix_chain = (
|
|
308
|
+
_when(exponent_idx == 0)
|
|
309
|
+
.then(_lit('B'))
|
|
310
|
+
.when(exponent_idx == 1)
|
|
311
|
+
.then(_lit('kB'))
|
|
312
|
+
.when(exponent_idx == 2)
|
|
313
|
+
.then(_lit('MB'))
|
|
314
|
+
.when(exponent_idx == 3)
|
|
315
|
+
.then(_lit('GB'))
|
|
316
|
+
.when(exponent_idx == 4)
|
|
317
|
+
.then(_lit('TB'))
|
|
318
|
+
.when(exponent_idx == 5)
|
|
319
|
+
.then(_lit('PB'))
|
|
320
|
+
.otherwise(_lit('B'))
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
divisor = _lit(base).pow(exponent_idx.cast(_Float64))
|
|
324
|
+
val_scaled = val / divisor
|
|
325
|
+
|
|
326
|
+
epsilon = _when(val_scaled >= 0).then(_lit(1e-9)).otherwise(_lit(-1e-9))
|
|
327
|
+
rounded = (val_scaled + epsilon).round(decimals)
|
|
328
|
+
|
|
329
|
+
if decimals > 0:
|
|
330
|
+
int_part = rounded.cast(_Int64).abs().cast(_String)
|
|
331
|
+
full_str = rounded.abs().cast(_String)
|
|
332
|
+
frac_part = (
|
|
333
|
+
_when(full_str.str.contains(r'\.'))
|
|
334
|
+
.then(full_str.str.split('.').list.get(1).str.slice(0, decimals))
|
|
335
|
+
.otherwise(_lit(''))
|
|
336
|
+
).str.pad_end(decimals, fill_char='0')
|
|
337
|
+
|
|
338
|
+
if use_seps:
|
|
339
|
+
int_part = (
|
|
340
|
+
int_part.str.reverse()
|
|
341
|
+
.str.replace_all(r'(\d{3})', r'${1},')
|
|
342
|
+
.str.strip_suffix(',')
|
|
343
|
+
.str.reverse()
|
|
344
|
+
)
|
|
345
|
+
base_num_str = int_part + _lit('.') + frac_part
|
|
346
|
+
else:
|
|
347
|
+
base_num_str = rounded.round(0).cast(_Int64).abs().cast(_String)
|
|
348
|
+
if use_seps:
|
|
349
|
+
base_num_str = (
|
|
350
|
+
base_num_str.str.reverse()
|
|
351
|
+
.str.replace_all(r'(\d{3})', r'${1},')
|
|
352
|
+
.str.strip_suffix(',')
|
|
353
|
+
.str.reverse()
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
base_num_str = (
|
|
357
|
+
_when(val < 0)
|
|
358
|
+
.then(_lit('-') + base_num_str)
|
|
359
|
+
.when((_lit(force_sign)) & (val > 0))
|
|
360
|
+
.then(_lit('+') + base_num_str)
|
|
361
|
+
.otherwise(base_num_str)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
space = _lit(' ') if incl_space else _lit('')
|
|
365
|
+
combined = base_num_str + space + suffix_chain
|
|
366
|
+
|
|
367
|
+
prefix, suffix = '', ''
|
|
368
|
+
if pattern != '{x}':
|
|
369
|
+
parts = pattern.split('{x}')
|
|
370
|
+
if len(parts) == 2:
|
|
371
|
+
prefix, suffix = parts[0], parts[1]
|
|
372
|
+
|
|
373
|
+
return _lit(prefix) + combined + _lit(suffix)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@_multicolumn
|
|
377
|
+
def fmt_tf(
|
|
378
|
+
columns: _Columns,
|
|
379
|
+
tf_style: _Literal[
|
|
380
|
+
'true-false',
|
|
381
|
+
'yes-no',
|
|
382
|
+
'up-down',
|
|
383
|
+
'check-mark',
|
|
384
|
+
'circles',
|
|
385
|
+
'squares',
|
|
386
|
+
'diamonds',
|
|
387
|
+
'arrows',
|
|
388
|
+
'triangles',
|
|
389
|
+
'triangles-lr',
|
|
390
|
+
] = 'true-false',
|
|
391
|
+
pattern: str = '{x}',
|
|
392
|
+
true_val: str | None = None,
|
|
393
|
+
false_val: str | None = None,
|
|
394
|
+
na_val: str | None = None,
|
|
395
|
+
) -> _Expr:
|
|
396
|
+
"""Highly optimized native boolean layout mapper."""
|
|
397
|
+
style_map = {
|
|
398
|
+
'true-false': ('true', 'false'),
|
|
399
|
+
'yes-no': ('yes', 'no'),
|
|
400
|
+
'up-down': ('up', 'down'),
|
|
401
|
+
'check-mark': ('✓', '✗'),
|
|
402
|
+
'circles': ('●', '○'),
|
|
403
|
+
'squares': ('■', '□'),
|
|
404
|
+
'diamonds': ('◆', '◇'),
|
|
405
|
+
'arrows': ('↑', '↓'),
|
|
406
|
+
'triangles': ('▲', '▼'),
|
|
407
|
+
'triangles-lr': ('▶', '◀'),
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
preset_true, preset_false = style_map.get(tf_style, ('true', 'false'))
|
|
411
|
+
final_true = true_val if true_val is not None else preset_true
|
|
412
|
+
final_false = false_val if false_val is not None else preset_false
|
|
413
|
+
|
|
414
|
+
base_expr = (
|
|
415
|
+
_when(_col(columns))
|
|
416
|
+
.then(_lit(final_true))
|
|
417
|
+
.otherwise(_lit(final_false))
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
prefix, suffix = '', ''
|
|
421
|
+
if pattern != '{x}':
|
|
422
|
+
parts = pattern.split('{x}')
|
|
423
|
+
if len(parts) == 2:
|
|
424
|
+
prefix, suffix = parts[0], parts[1]
|
|
425
|
+
|
|
426
|
+
formatted_expr = _lit(prefix) + base_expr + _lit(suffix)
|
|
427
|
+
|
|
428
|
+
if na_val is not None:
|
|
429
|
+
return (
|
|
430
|
+
_when(_col(columns).is_null())
|
|
431
|
+
.then(_lit(na_val))
|
|
432
|
+
.otherwise(formatted_expr)
|
|
433
|
+
)
|
|
434
|
+
return (
|
|
435
|
+
_when(_col(columns).is_null())
|
|
436
|
+
.then(_lit(None))
|
|
437
|
+
.otherwise(formatted_expr)
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@_multicolumn
|
|
442
|
+
def sub_missing(columns: _Columns, missing_text: str = '—') -> _Expr:
|
|
443
|
+
return _col(columns).fill_null(_lit(missing_text))
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@_multicolumn
|
|
447
|
+
def sub_zero(columns: _Columns, zero_text: str = '—') -> _Expr:
|
|
448
|
+
return (
|
|
449
|
+
_when(_col(columns) == 0)
|
|
450
|
+
.then(_lit(zero_text))
|
|
451
|
+
.otherwise(_col(columns))
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@_multicolumn
|
|
456
|
+
def fmt_integer(
|
|
457
|
+
columns: _Columns,
|
|
458
|
+
scale_by: float = 1.0,
|
|
459
|
+
compact: bool = False,
|
|
460
|
+
compact_system: _Literal['financial', 'engineering'] = 'financial',
|
|
461
|
+
use_seps: bool = True,
|
|
462
|
+
accounting: bool = False,
|
|
463
|
+
pattern: str = '{x}',
|
|
464
|
+
) -> _Expr:
|
|
465
|
+
"""Highly optimized native Polars integer formatter wrapper."""
|
|
466
|
+
return fmt_number(
|
|
467
|
+
columns=columns,
|
|
468
|
+
decimals=0,
|
|
469
|
+
scale_by=scale_by,
|
|
470
|
+
compact=compact,
|
|
471
|
+
compact_system=compact_system,
|
|
472
|
+
use_seps=use_seps,
|
|
473
|
+
accounting=accounting,
|
|
474
|
+
pattern=pattern,
|
|
475
|
+
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from polars import (
|
|
2
|
+
Expr as _Expr,
|
|
3
|
+
Float64 as _Float64,
|
|
4
|
+
Int32 as _Int32,
|
|
5
|
+
String as _String,
|
|
6
|
+
col as _col,
|
|
7
|
+
len as _len,
|
|
8
|
+
lit as _lit,
|
|
9
|
+
when as _when,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _interpolate_segment(
|
|
14
|
+
norm_val: _Expr,
|
|
15
|
+
low_hex: str,
|
|
16
|
+
high_hex: str,
|
|
17
|
+
seg_start: float,
|
|
18
|
+
segment_width: float,
|
|
19
|
+
) -> tuple[_Expr, _Expr, _Expr]:
|
|
20
|
+
"""Calculates the dynamic RGB expressions for a specific color segment."""
|
|
21
|
+
# Parse hex strings into base integer components
|
|
22
|
+
r1, g1, b1 = (
|
|
23
|
+
int(low_hex[1:3], 16),
|
|
24
|
+
int(low_hex[3:5], 16),
|
|
25
|
+
int(low_hex[5:7], 16),
|
|
26
|
+
)
|
|
27
|
+
r2, g2, b2 = (
|
|
28
|
+
int(high_hex[1:3], 16),
|
|
29
|
+
int(high_hex[3:5], 16),
|
|
30
|
+
int(high_hex[5:7], 16),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Compute local advancement factor within this specific segment channel
|
|
34
|
+
local_factor = (norm_val - seg_start) / segment_width
|
|
35
|
+
|
|
36
|
+
# Linear interpolation formula applied directly to Polars Expressions
|
|
37
|
+
r_expr = (_lit(r1) + (_lit(r2 - r1) * local_factor)).round(0).cast(_Int32)
|
|
38
|
+
g_expr = (_lit(g1) + (_lit(g2 - g1) * local_factor)).round(0).cast(_Int32)
|
|
39
|
+
b_expr = (_lit(b1) + (_lit(b2 - b1) * local_factor)).round(0).cast(_Int32)
|
|
40
|
+
|
|
41
|
+
return r_expr, g_expr, b_expr
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_css_expression(
|
|
45
|
+
r: _Expr, g: _Expr, b: _Expr, auto_contrast: bool
|
|
46
|
+
) -> _Expr:
|
|
47
|
+
"""Stitches RGB expressions into a complete background and foreground style string."""
|
|
48
|
+
base_style = (
|
|
49
|
+
_lit('background-color: rgb(')
|
|
50
|
+
+ r.cast(_String)
|
|
51
|
+
+ _lit(',')
|
|
52
|
+
+ g.cast(_String)
|
|
53
|
+
+ _lit(',')
|
|
54
|
+
+ b.cast(_String)
|
|
55
|
+
+ _lit(');')
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if not auto_contrast:
|
|
59
|
+
return base_style
|
|
60
|
+
|
|
61
|
+
# WCAG Relative Luminance Formula matching standard text accessibility bounds
|
|
62
|
+
luminance = (
|
|
63
|
+
(0.2126 * (r / 255.0))
|
|
64
|
+
+ (0.7152 * (g / 255.0))
|
|
65
|
+
+ (0.0722 * (b / 255.0))
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
fg_color = (
|
|
69
|
+
_when(luminance < 0.45)
|
|
70
|
+
.then(_lit(' color: #FFFFFF;'))
|
|
71
|
+
.otherwise(_lit(' color: #000000;'))
|
|
72
|
+
)
|
|
73
|
+
return base_style + fg_color
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def data_color(
|
|
77
|
+
column: str,
|
|
78
|
+
palette: list[str],
|
|
79
|
+
domain: tuple[float, float] | None = None,
|
|
80
|
+
auto_contrast: bool = True,
|
|
81
|
+
) -> dict[str, dict[str, _Expr]]:
|
|
82
|
+
"""
|
|
83
|
+
Generates a style attribute expression mapping values in a column to a hex color palette.
|
|
84
|
+
If domain is None, it dynamically calculates min/max from the column at evaluation time.
|
|
85
|
+
"""
|
|
86
|
+
if len(palette) < 2:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
'Palette must contain at least 2 colors for interpolation.'
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# 1. Evaluate Data Domain and Normalize Boundaries
|
|
92
|
+
min_expr = _lit(domain[0]) if domain else _col(column).min()
|
|
93
|
+
max_expr = _lit(domain[1]) if domain else _col(column).max()
|
|
94
|
+
range_expr = max_expr - min_expr
|
|
95
|
+
|
|
96
|
+
norm_val = (
|
|
97
|
+
_when(range_expr == 0)
|
|
98
|
+
.then(_lit(0.0))
|
|
99
|
+
.otherwise((_col(column) - min_expr) / range_expr)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
num_segments = len(palette) - 1
|
|
103
|
+
segment_width = 1.0 / num_segments
|
|
104
|
+
|
|
105
|
+
# 2. Build the Initial Segment Boundary Condition
|
|
106
|
+
r, g, b = _interpolate_segment(
|
|
107
|
+
norm_val, palette[0], palette[1], 0.0, segment_width
|
|
108
|
+
)
|
|
109
|
+
chain = _when(norm_val <= segment_width).then(
|
|
110
|
+
_build_css_expression(r, g, b, auto_contrast)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# 3. Chain Remaining Segments Sequentially
|
|
114
|
+
for i in range(1, num_segments):
|
|
115
|
+
seg_start = i * segment_width
|
|
116
|
+
seg_end = (i + 1) * segment_width
|
|
117
|
+
|
|
118
|
+
r, g, b = _interpolate_segment(
|
|
119
|
+
norm_val, palette[i], palette[i + 1], seg_start, segment_width
|
|
120
|
+
)
|
|
121
|
+
chain = chain.when(norm_val <= seg_end).then(
|
|
122
|
+
_build_css_expression(r, g, b, auto_contrast)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return {column: {'style': chain.otherwise(_lit(''))}}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def rank_color(
|
|
129
|
+
column: str,
|
|
130
|
+
palette: list[str],
|
|
131
|
+
descending: bool = False,
|
|
132
|
+
auto_contrast: bool = True,
|
|
133
|
+
) -> dict[str, dict[str, _Expr]]:
|
|
134
|
+
"""
|
|
135
|
+
Generates a style attribute expression mapping values in a column to a hex color palette
|
|
136
|
+
based on their ordinal rank rather than their absolute value.
|
|
137
|
+
"""
|
|
138
|
+
if len(palette) < 2:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
'Palette must contain at least 2 colors for interpolation.'
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# 1. Dynamically compute the ordinal rank (1-indexed) inside Polars
|
|
144
|
+
# Subtract 1 to make it 0-indexed: [0, n-1]
|
|
145
|
+
rank_expr = _col(column).rank(method='ordinal', descending=descending) - 1
|
|
146
|
+
|
|
147
|
+
# 2. Safely normalize the rank between 0.0 and 1.0 based on dataset size
|
|
148
|
+
# norm = rank / (total_rows - 1)
|
|
149
|
+
total_rows_minus_1 = _len().cast(_Float64) - 1.0
|
|
150
|
+
|
|
151
|
+
norm_val = (
|
|
152
|
+
_when(total_rows_minus_1 <= 0)
|
|
153
|
+
.then(_lit(0.0))
|
|
154
|
+
.otherwise(rank_expr.cast(_Float64) / total_rows_minus_1)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
num_segments = len(palette) - 1
|
|
158
|
+
segment_width = 1.0 / num_segments
|
|
159
|
+
|
|
160
|
+
# 3. Reuse our optimized piece-wise interpolation pipeline
|
|
161
|
+
r, g, b = _interpolate_segment(
|
|
162
|
+
norm_val, palette[0], palette[1], 0.0, segment_width
|
|
163
|
+
)
|
|
164
|
+
chain = _when(norm_val <= segment_width).then(
|
|
165
|
+
_build_css_expression(r, g, b, auto_contrast)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
for i in range(1, num_segments):
|
|
169
|
+
seg_start = i * segment_width
|
|
170
|
+
seg_end = (i + 1) * segment_width
|
|
171
|
+
|
|
172
|
+
r, g, b = _interpolate_segment(
|
|
173
|
+
norm_val, palette[i], palette[i + 1], seg_start, segment_width
|
|
174
|
+
)
|
|
175
|
+
chain = chain.when(norm_val <= seg_end).then(
|
|
176
|
+
_build_css_expression(r, g, b, auto_contrast)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return {column: {'style': chain.otherwise(_lit(''))}}
|
pl2html-0.1.0/PKG-INFO
DELETED
pl2html-0.1.0/README.md
DELETED
|
File without changes
|