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 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>&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
+ ```
@@ -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>&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
+ ```
@@ -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.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
- 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,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(''))}}
@@ -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.2.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