pl2html 0.1.0__tar.gz → 0.3.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.3.0/PKG-INFO ADDED
@@ -0,0 +1,217 @@
1
+ Metadata-Version: 2.3
2
+ Name: pl2html
3
+ Version: 0.3.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>&lt;script&gt;alert(&#x27;malicious&#x27;)&lt;/script&gt; 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('&', '&amp;')
203
+ .str.replace_all('<', '&lt;')
204
+ .str.replace_all('>', '&gt;')
205
+ .str.replace_all('"', '&quot;')
206
+ .str.replace_all("'", '&#x27;')
207
+ ```
208
+
209
+ ### Design Inspiration & Why This Library Exists
210
+
211
+ Most of the API design and formatting parameters in this library are heavily inspired by [great-tables](https://posit-dev.github.io/great-tables/reference/). If you are looking for detailed descriptions or conceptual overviews of specific formatting arguments, the `great-tables` reference documentation serves as an excellent companion guide.
212
+
213
+ However, this library was born out of distinct architectural and environment constraints:
214
+
215
+ * **Zero Overhead Styling:** While `great-tables` is incredibly feature-rich, it often produces dense HTML styles and boilerplate structures that were unnecessary for the target use cases. This library optimizes for minimal, highly clean HTML output.
216
+ * **Modern Python Compatibility (Py 3.14+):** `great-tables` pulls in heavy external dependencies, including binary wheels like `multimark`. At the time of development, these dependencies did not support Python 3.14. To unblock workflows and maintain an ultra-lightweight, future-proof stack with zero complex binary dependencies, this native, Rust-backed Polars formatter was built from the ground up.
217
+
@@ -0,0 +1,208 @@
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>&lt;script&gt;alert(&#x27;malicious&#x27;)&lt;/script&gt; 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('&', '&amp;')
194
+ .str.replace_all('<', '&lt;')
195
+ .str.replace_all('>', '&gt;')
196
+ .str.replace_all('"', '&quot;')
197
+ .str.replace_all("'", '&#x27;')
198
+ ```
199
+
200
+ ### Design Inspiration & Why This Library Exists
201
+
202
+ Most of the API design and formatting parameters in this library are heavily inspired by [great-tables](https://posit-dev.github.io/great-tables/reference/). If you are looking for detailed descriptions or conceptual overviews of specific formatting arguments, the `great-tables` reference documentation serves as an excellent companion guide.
203
+
204
+ However, this library was born out of distinct architectural and environment constraints:
205
+
206
+ * **Zero Overhead Styling:** While `great-tables` is incredibly feature-rich, it often produces dense HTML styles and boilerplate structures that were unnecessary for the target use cases. This library optimizes for minimal, highly clean HTML output.
207
+ * **Modern Python Compatibility (Py 3.14+):** `great-tables` pulls in heavy external dependencies, including binary wheels like `multimark`. At the time of development, these dependencies did not support Python 3.14. To unblock workflows and maintain an ultra-lightweight, future-proof stack with zero complex binary dependencies, this native, Rust-backed Polars formatter was built from the ground up.
208
+
@@ -1,9 +1,19 @@
1
- import polars as pl
2
-
3
- __version__ = '0.1.0'
4
-
5
-
6
- def _escape_polars_string(col_name: str) -> pl.Expr:
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.3.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
- pl.col(col_name)
15
- .cast(pl.String)
24
+ _col(col_name)
25
+ .cast(_String)
16
26
  .str.replace_all('&', '&amp;')
17
27
  .str.replace_all('<', '&lt;')
18
28
  .str.replace_all('>', '&gt;')
@@ -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) -> pl.Expr:
34
+ def _format_integer(col_name: str) -> _Expr:
25
35
  """Adds thousands separators to integers using look-around-free logic."""
26
36
  return (
27
- pl.col(col_name)
37
+ _col(col_name)
28
38
  .fill_null(0)
29
- .cast(pl.String)
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) -> pl.Expr:
48
+ def _format_float(col_name: str) -> _Expr:
39
49
  """Formats floats with 3 decimals and thousands separators."""
40
- rounded_str = pl.col(col_name).round(3).cast(pl.String)
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
- pl.when(dec_part.is_not_null() & (dec_part != ''))
54
- .then(formatted_int + pl.lit('.') + dec_part)
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: pl.DataType,
62
- attrs: dict[str, dict[str, pl.Expr]],
63
- ) -> pl.Expr:
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
- pl.when(val_expr.is_not_null() & (val_expr != ''))
94
+ _when(val_expr.is_not_null() & (val_expr != ''))
85
95
  .then(
86
- pl.lit(f' {attr_name}="')
87
- + val_expr.cast(pl.String)
88
- + pl.lit('"')
96
+ _lit(f' {attr_name}="')
97
+ + val_expr.cast(_String)
98
+ + _lit('"')
89
99
  )
90
- .otherwise(pl.lit(''))
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 = pl.lit('<td>')
108
+ opening_td = _lit('<td>')
101
109
 
102
- return opening_td + fmt_expr + pl.lit('</td>')
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 = pl.lit('\n'.join(header_parts) + '\n')
116
- html_footer = pl.lit('\n </tbody>\n</table>')
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: pl.DataFrame | pl.LazyFrame,
125
+ df: _DataFrame | _LazyFrame,
122
126
  *,
123
- attrs: dict[str, dict[str, pl.Expr]] | None = None,
127
+ attrs: dict[str, dict[str, _Expr]] | None = None,
124
128
  exclude_columns: list[str] | None = None,
125
- add_row_no: bool = True,
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, pl.DataFrame) else 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 + pl.col('html_row').str.join('\n') + html_footer).alias(
155
+ (html_header + _col('html_row').str.join('\n') + html_footer).alias(
166
156
  'html_table'
167
157
  )
168
158
  )
@@ -0,0 +1,532 @@
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], **kwargs):
21
+ if isinstance(columns, str):
22
+ return func(columns=columns, **kwargs)
23
+ return [func(columns=col, **kwargs).alias(col) for col in columns]
24
+
25
+ return wrapper
26
+
27
+
28
+ # --- 1. Formatter Implementation Block ---
29
+
30
+ _Columns = str | list[str]
31
+
32
+
33
+ @_multicolumn
34
+ def fmt_number(
35
+ *,
36
+ columns: _Columns,
37
+ decimals: int = 2,
38
+ scale_by: float = 1.0,
39
+ compact: bool = False,
40
+ compact_system: _Literal['financial', 'engineering'] = 'financial',
41
+ use_seps: bool = True,
42
+ accounting: bool = False,
43
+ pattern: str = '{x}',
44
+ n_sigfig: int | None = None,
45
+ force_sign: bool = False,
46
+ ) -> _Expr:
47
+ """
48
+ Highly optimized, native Polars numeric formatter matching great_tables features.
49
+ Runs entirely in the parallel Rust engine without dropping into Python row loops.
50
+ """
51
+ val = _col(columns)
52
+ if scale_by != 1.0:
53
+ val = val * scale_by
54
+
55
+ abs_val = val.abs()
56
+
57
+ # 1. Extract compact scaling suffix chain if enabled
58
+ if compact:
59
+ log10_expr = (
60
+ _when(abs_val > 0).then(abs_val.log10()).otherwise(_lit(0.0))
61
+ )
62
+ thousands_exponent = (log10_expr / 3.0).floor().cast(_Int32) * 3
63
+
64
+ if compact_system == 'engineering':
65
+ suffix_chain = (
66
+ _when(thousands_exponent == 3)
67
+ .then(_lit('k'))
68
+ .when(thousands_exponent == 6)
69
+ .then(_lit('M'))
70
+ .when(thousands_exponent == 9)
71
+ .then(_lit('G'))
72
+ .when(thousands_exponent == 12)
73
+ .then(_lit('T'))
74
+ .otherwise(_lit(''))
75
+ )
76
+ else:
77
+ suffix_chain = (
78
+ _when(thousands_exponent == 3)
79
+ .then(_lit('K'))
80
+ .when(thousands_exponent == 6)
81
+ .then(_lit('M'))
82
+ .when(thousands_exponent == 9)
83
+ .then(_lit('B'))
84
+ .when(thousands_exponent == 12)
85
+ .then(_lit('T'))
86
+ .otherwise(_lit(''))
87
+ )
88
+ divisor = _lit(10.0).pow(thousands_exponent.cast(_Float64))
89
+ val_scaled = val / divisor
90
+ else:
91
+ val_scaled = val
92
+ suffix_chain = _lit('')
93
+
94
+ # 2. Dynamic Precision Handling (Significant Figures vs Fixed Decimals)
95
+ if n_sigfig is not None:
96
+ if n_sigfig < 1:
97
+ raise ValueError('n_sigfig must be a positive integer >= 1')
98
+
99
+ # Round natively using Polars' built-in significant figures feature
100
+ rounded = val_scaled.round_sig_figs(n_sigfig)
101
+ abs_rounded = rounded.abs()
102
+
103
+ # Determine the number of dynamic decimal places needed for padding per row
104
+ log10_expr = (
105
+ _when(abs_rounded > 0)
106
+ .then(abs_rounded.log10())
107
+ .otherwise(_lit(0.0))
108
+ )
109
+ # decimals required = n_sigfig - 1 - floor(log10(x))
110
+ dynamic_decimals = (_lit(n_sigfig - 1) - log10_expr.floor()).cast(
111
+ _Int32
112
+ )
113
+ # Ensure we don't try to pad negative decimal places for large numbers
114
+ dynamic_decimals = (
115
+ _when(dynamic_decimals < 0)
116
+ .then(_lit(0))
117
+ .otherwise(dynamic_decimals)
118
+ )
119
+ else:
120
+ # Fallback to standard fixed decimal logic
121
+ epsilon = (
122
+ _when(val_scaled >= 0).then(_lit(1e-9)).otherwise(_lit(-1e-9))
123
+ )
124
+ rounded = (val_scaled + epsilon).round(decimals)
125
+ dynamic_decimals = _lit(decimals)
126
+
127
+ # 3. Component Extraction & Alignment
128
+ int_part = rounded.cast(_Int64).abs().cast(_String)
129
+ full_str = rounded.abs().cast(_String)
130
+
131
+ raw_frac = (
132
+ _when(full_str.str.contains(r'\.'))
133
+ .then(full_str.str.split('.').list.get(1))
134
+ .otherwise(_lit(''))
135
+ )
136
+
137
+ # Use native Python string multiplication inside _lit()
138
+ pad_len = n_sigfig if n_sigfig is not None else decimals
139
+ frac_part = (
140
+ _when(dynamic_decimals > 0)
141
+ .then((raw_frac + _lit('0' * pad_len)).str.slice(0, dynamic_decimals))
142
+ .otherwise(_lit(''))
143
+ )
144
+
145
+ # 4. Constructing Base Number String
146
+ if use_seps:
147
+ int_part = (
148
+ int_part.str.reverse()
149
+ .str.replace_all(r'(\d{3})', r'${1},')
150
+ .str.strip_suffix(',')
151
+ .str.reverse()
152
+ )
153
+
154
+ base_num_str = (
155
+ _when(dynamic_decimals > 0)
156
+ .then(int_part + _lit('.') + frac_part)
157
+ .otherwise(int_part)
158
+ )
159
+ base_num_str = base_num_str + suffix_chain
160
+
161
+ prefix, suffix = '', ''
162
+ if pattern != '{x}':
163
+ parts = pattern.split('{x}')
164
+ if len(parts) == 2:
165
+ prefix, suffix = parts[0], parts[1]
166
+
167
+ if accounting:
168
+ formatted_expr = (
169
+ _when(val < 0)
170
+ .then(
171
+ _lit('(')
172
+ + _lit(prefix)
173
+ + base_num_str
174
+ + _lit(suffix)
175
+ + _lit(')')
176
+ )
177
+ .otherwise(
178
+ # If force_sign is True, explicitly prepend '+' to positive values
179
+ _lit('+' if force_sign else '')
180
+ + _lit(prefix)
181
+ + base_num_str
182
+ + _lit(suffix)
183
+ )
184
+ )
185
+ else:
186
+ formatted_expr = (
187
+ _when(val < 0)
188
+ .then(_lit('-') + _lit(prefix) + base_num_str + _lit(suffix))
189
+ # Handle zero explicitly if you don't want a sign on exactly 0.0
190
+ .when((val == 0) | (not force_sign))
191
+ .then(_lit(prefix) + base_num_str + _lit(suffix))
192
+ .otherwise(_lit('+') + _lit(prefix) + base_num_str + _lit(suffix))
193
+ )
194
+
195
+ return formatted_expr
196
+
197
+
198
+ @_multicolumn
199
+ def fmt_percent(
200
+ *,
201
+ columns: _Columns,
202
+ decimals: int = 2,
203
+ use_seps: bool = True,
204
+ force_sign: bool = False,
205
+ ) -> _Expr:
206
+ """Formats columns as percentages, multiplying by 100 automatically."""
207
+ return fmt_number(
208
+ columns=columns,
209
+ decimals=decimals,
210
+ scale_by=100.0,
211
+ use_seps=use_seps,
212
+ pattern='{x}%',
213
+ force_sign=force_sign,
214
+ )
215
+
216
+
217
+ @_multicolumn
218
+ def fmt_currency(
219
+ *,
220
+ columns: _Columns,
221
+ symbol: str = '$',
222
+ decimals: int = 2,
223
+ use_seps: bool = True,
224
+ accounting: bool = True,
225
+ force_sign: bool = False,
226
+ ) -> _Expr:
227
+ """Formats columns as localized currency values."""
228
+ return fmt_number(
229
+ columns=columns,
230
+ decimals=decimals,
231
+ use_seps=use_seps,
232
+ accounting=accounting,
233
+ pattern=f'{symbol}{{x}}',
234
+ force_sign=force_sign,
235
+ )
236
+
237
+
238
+ @_multicolumn
239
+ def fmt_scientific(
240
+ *,
241
+ columns: _Columns,
242
+ decimals: int = 2,
243
+ scale_by: float = 1.0,
244
+ exp_style: _Literal['x10n', 'e', 'E'] = 'x10n',
245
+ pattern: str = '{x}',
246
+ force_sign_m: bool = False,
247
+ force_sign_n: bool = False,
248
+ ) -> _Expr:
249
+ """Highly optimized scientific notation formatter."""
250
+ val = _col(columns)
251
+ if scale_by != 1.0:
252
+ val = val * scale_by
253
+
254
+ abs_val = val.abs()
255
+ log10_expr = _when(abs_val > 0).then(abs_val.log10()).otherwise(_lit(0.0))
256
+ exponent = log10_expr.floor().cast(_Int32)
257
+ exponent = _when(val == 0).then(_lit(0)).otherwise(exponent)
258
+
259
+ divisor = _lit(10.0).pow(exponent.cast(_Float64))
260
+ mantissa = val / divisor
261
+
262
+ epsilon = _when(mantissa >= 0).then(_lit(1e-9)).otherwise(_lit(-1e-9))
263
+ rounded_m = (mantissa + epsilon).round(decimals)
264
+
265
+ if decimals > 0:
266
+ int_m = rounded_m.cast(_Int64).abs().cast(_String)
267
+ full_str_m = rounded_m.abs().cast(_String)
268
+ frac_m = (
269
+ _when(full_str_m.str.contains(r'\.'))
270
+ .then(full_str_m.str.split('.').list.get(1).str.slice(0, decimals))
271
+ .otherwise(_lit(''))
272
+ ).str.pad_end(decimals, fill_char='0')
273
+ m_str = int_m + _lit('.') + frac_m
274
+ else:
275
+ m_str = rounded_m.round(0).cast(_Int64).abs().cast(_String)
276
+
277
+ m_str = (
278
+ _when(val < 0)
279
+ .then(_lit('-') + m_str)
280
+ .when((_lit(force_sign_m)) & (val > 0))
281
+ .then(_lit('+') + m_str)
282
+ .otherwise(m_str)
283
+ )
284
+
285
+ n_str = exponent.abs().cast(_String)
286
+ n_str = (
287
+ _when(exponent < 0)
288
+ .then(_lit('-') + n_str)
289
+ .when((_lit(force_sign_n)) & (exponent > 0))
290
+ .then(_lit('+') + n_str)
291
+ .otherwise(
292
+ _when(_lit(force_sign_n)).then(_lit('+') + n_str).otherwise(n_str)
293
+ )
294
+ )
295
+
296
+ if exp_style == 'x10n':
297
+ combined = m_str + _lit(' × 10^') + n_str
298
+ elif exp_style == 'E':
299
+ combined = m_str + _lit('E') + n_str
300
+ else:
301
+ combined = m_str + _lit('e') + n_str
302
+
303
+ prefix, suffix = '', ''
304
+ if pattern != '{x}':
305
+ parts = pattern.split('{x}')
306
+ if len(parts) == 2:
307
+ prefix, suffix = parts[0], parts[1]
308
+
309
+ return _lit(prefix) + combined + _lit(suffix)
310
+
311
+
312
+ @_multicolumn
313
+ def fmt_bytes(
314
+ *,
315
+ columns: _Columns,
316
+ standard: _Literal['decimal', 'binary'] = 'decimal',
317
+ decimals: int = 1,
318
+ use_seps: bool = True,
319
+ pattern: str = '{x}',
320
+ force_sign: bool = False,
321
+ incl_space: bool = True,
322
+ ) -> _Expr:
323
+ """Highly optimized native bytes formatter."""
324
+ val = _col(columns)
325
+ abs_val = val.abs()
326
+
327
+ if standard == 'binary':
328
+ base = 1024.0
329
+ log_expr = (
330
+ _when(abs_val > 0).then(abs_val.log(2.0)).otherwise(_lit(0.0))
331
+ )
332
+ exponent_idx = (log_expr / 10.0).floor().cast(_Int32)
333
+ exponent_idx = (
334
+ _when(exponent_idx < 0).then(_lit(0)).otherwise(exponent_idx)
335
+ )
336
+ suffix_chain = (
337
+ _when(exponent_idx == 0)
338
+ .then(_lit('B'))
339
+ .when(exponent_idx == 1)
340
+ .then(_lit('KiB'))
341
+ .when(exponent_idx == 2)
342
+ .then(_lit('MiB'))
343
+ .when(exponent_idx == 3)
344
+ .then(_lit('GiB'))
345
+ .when(exponent_idx == 4)
346
+ .then(_lit('TiB'))
347
+ .when(exponent_idx == 5)
348
+ .then(_lit('PiB'))
349
+ .otherwise(_lit('B'))
350
+ )
351
+ else:
352
+ base = 1000.0
353
+ log_expr = (
354
+ _when(abs_val > 0).then(abs_val.log10()).otherwise(_lit(0.0))
355
+ )
356
+ exponent_idx = (log_expr / 3.0).floor().cast(_Int32)
357
+ exponent_idx = (
358
+ _when(exponent_idx < 0).then(_lit(0)).otherwise(exponent_idx)
359
+ )
360
+ suffix_chain = (
361
+ _when(exponent_idx == 0)
362
+ .then(_lit('B'))
363
+ .when(exponent_idx == 1)
364
+ .then(_lit('kB'))
365
+ .when(exponent_idx == 2)
366
+ .then(_lit('MB'))
367
+ .when(exponent_idx == 3)
368
+ .then(_lit('GB'))
369
+ .when(exponent_idx == 4)
370
+ .then(_lit('TB'))
371
+ .when(exponent_idx == 5)
372
+ .then(_lit('PB'))
373
+ .otherwise(_lit('B'))
374
+ )
375
+
376
+ divisor = _lit(base).pow(exponent_idx.cast(_Float64))
377
+ val_scaled = val / divisor
378
+
379
+ epsilon = _when(val_scaled >= 0).then(_lit(1e-9)).otherwise(_lit(-1e-9))
380
+ rounded = (val_scaled + epsilon).round(decimals)
381
+
382
+ if decimals > 0:
383
+ int_part = rounded.cast(_Int64).abs().cast(_String)
384
+ full_str = rounded.abs().cast(_String)
385
+ frac_part = (
386
+ _when(full_str.str.contains(r'\.'))
387
+ .then(full_str.str.split('.').list.get(1).str.slice(0, decimals))
388
+ .otherwise(_lit(''))
389
+ ).str.pad_end(decimals, fill_char='0')
390
+
391
+ if use_seps:
392
+ int_part = (
393
+ int_part.str.reverse()
394
+ .str.replace_all(r'(\d{3})', r'${1},')
395
+ .str.strip_suffix(',')
396
+ .str.reverse()
397
+ )
398
+ base_num_str = int_part + _lit('.') + frac_part
399
+ else:
400
+ base_num_str = rounded.round(0).cast(_Int64).abs().cast(_String)
401
+ if use_seps:
402
+ base_num_str = (
403
+ base_num_str.str.reverse()
404
+ .str.replace_all(r'(\d{3})', r'${1},')
405
+ .str.strip_suffix(',')
406
+ .str.reverse()
407
+ )
408
+
409
+ base_num_str = (
410
+ _when(val < 0)
411
+ .then(_lit('-') + base_num_str)
412
+ .when((_lit(force_sign)) & (val > 0))
413
+ .then(_lit('+') + base_num_str)
414
+ .otherwise(base_num_str)
415
+ )
416
+
417
+ space = _lit(' ') if incl_space else _lit('')
418
+ combined = base_num_str + space + suffix_chain
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
+ return _lit(prefix) + combined + _lit(suffix)
427
+
428
+
429
+ @_multicolumn
430
+ def fmt_tf(
431
+ *,
432
+ columns: _Columns,
433
+ tf_style: _Literal[
434
+ 'true-false',
435
+ 'yes-no',
436
+ 'up-down',
437
+ 'check-mark',
438
+ 'circles',
439
+ 'squares',
440
+ 'diamonds',
441
+ 'arrows',
442
+ 'triangles',
443
+ 'triangles-lr',
444
+ ] = 'true-false',
445
+ pattern: str = '{x}',
446
+ true_val: str | None = None,
447
+ false_val: str | None = None,
448
+ na_val: str | None = None,
449
+ ) -> _Expr:
450
+ """Highly optimized native boolean layout mapper."""
451
+ style_map = {
452
+ 'true-false': ('true', 'false'),
453
+ 'yes-no': ('yes', 'no'),
454
+ 'up-down': ('up', 'down'),
455
+ 'check-mark': ('✓', '✗'),
456
+ 'circles': ('●', '○'),
457
+ 'squares': ('■', '□'),
458
+ 'diamonds': ('◆', '◇'),
459
+ 'arrows': ('↑', '↓'),
460
+ 'triangles': ('▲', '▼'),
461
+ 'triangles-lr': ('▶', '◀'),
462
+ }
463
+
464
+ preset_true, preset_false = style_map.get(tf_style, ('true', 'false'))
465
+ final_true = true_val if true_val is not None else preset_true
466
+ final_false = false_val if false_val is not None else preset_false
467
+
468
+ base_expr = (
469
+ _when(_col(columns))
470
+ .then(_lit(final_true))
471
+ .otherwise(_lit(final_false))
472
+ )
473
+
474
+ prefix, suffix = '', ''
475
+ if pattern != '{x}':
476
+ parts = pattern.split('{x}')
477
+ if len(parts) == 2:
478
+ prefix, suffix = parts[0], parts[1]
479
+
480
+ formatted_expr = _lit(prefix) + base_expr + _lit(suffix)
481
+
482
+ if na_val is not None:
483
+ return (
484
+ _when(_col(columns).is_null())
485
+ .then(_lit(na_val))
486
+ .otherwise(formatted_expr)
487
+ )
488
+ return (
489
+ _when(_col(columns).is_null())
490
+ .then(_lit(None))
491
+ .otherwise(formatted_expr)
492
+ )
493
+
494
+
495
+ @_multicolumn
496
+ def sub_missing(*, columns: _Columns, missing_text: str = '—') -> _Expr:
497
+ return _col(columns).fill_null(_lit(missing_text))
498
+
499
+
500
+ @_multicolumn
501
+ def sub_zero(*, columns: _Columns, zero_text: str = '—') -> _Expr:
502
+ return (
503
+ _when(_col(columns) == 0)
504
+ .then(_lit(zero_text))
505
+ .otherwise(_col(columns))
506
+ )
507
+
508
+
509
+ @_multicolumn
510
+ def fmt_integer(
511
+ *,
512
+ columns: _Columns,
513
+ scale_by: float = 1.0,
514
+ compact: bool = False,
515
+ compact_system: _Literal['financial', 'engineering'] = 'financial',
516
+ use_seps: bool = True,
517
+ accounting: bool = False,
518
+ pattern: str = '{x}',
519
+ force_sign: bool = False,
520
+ ) -> _Expr:
521
+ """Highly optimized native Polars integer formatter wrapper."""
522
+ return fmt_number(
523
+ columns=columns,
524
+ decimals=0,
525
+ scale_by=scale_by,
526
+ compact=compact,
527
+ compact_system=compact_system,
528
+ use_seps=use_seps,
529
+ accounting=accounting,
530
+ pattern=pattern,
531
+ force_sign=force_sign,
532
+ )
@@ -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(''))}}
@@ -4,7 +4,7 @@ build-backend = 'uv_build'
4
4
 
5
5
  [project]
6
6
  name = "pl2html"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  description = "Add your description here"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
pl2html-0.1.0/PKG-INFO DELETED
@@ -1,9 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: pl2html
3
- Version: 0.1.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
-
pl2html-0.1.0/README.md DELETED
File without changes