pysofra 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. pysofra/__init__.py +82 -0
  2. pysofra/core/__init__.py +14 -0
  3. pysofra/core/compose.py +167 -0
  4. pysofra/core/format.py +155 -0
  5. pysofra/core/frames.py +69 -0
  6. pysofra/core/schema.py +128 -0
  7. pysofra/core/table.py +924 -0
  8. pysofra/io/__init__.py +1 -0
  9. pysofra/models/__init__.py +6 -0
  10. pysofra/models/extract.py +249 -0
  11. pysofra/models/pool.py +119 -0
  12. pysofra/models/regression.py +507 -0
  13. pysofra/models/survival.py +395 -0
  14. pysofra/models/uvregression.py +438 -0
  15. pysofra/notebook/__init__.py +6 -0
  16. pysofra/plot/__init__.py +23 -0
  17. pysofra/plot/_backend.py +32 -0
  18. pysofra/plot/forest.py +159 -0
  19. pysofra/plot/inline.py +171 -0
  20. pysofra/plot/km.py +249 -0
  21. pysofra/render/__init__.py +28 -0
  22. pysofra/render/_zip_determinism.py +57 -0
  23. pysofra/render/base.py +22 -0
  24. pysofra/render/docx.py +286 -0
  25. pysofra/render/html.py +442 -0
  26. pysofra/render/image.py +130 -0
  27. pysofra/render/latex.py +253 -0
  28. pysofra/render/markdown.py +128 -0
  29. pysofra/render/pptx.py +340 -0
  30. pysofra/render/xlsx.py +226 -0
  31. pysofra/summary/__init__.py +6 -0
  32. pysofra/summary/calibrate.py +214 -0
  33. pysofra/summary/design.py +246 -0
  34. pysofra/summary/effect_size.py +187 -0
  35. pysofra/summary/extras.py +745 -0
  36. pysofra/summary/smd.py +133 -0
  37. pysofra/summary/stats.py +135 -0
  38. pysofra/summary/tbl_cross.py +339 -0
  39. pysofra/summary/tbl_one.py +1220 -0
  40. pysofra/summary/tbl_summary.py +51 -0
  41. pysofra/summary/tests.py +370 -0
  42. pysofra/summary/typing.py +129 -0
  43. pysofra/summary/weights.py +161 -0
  44. pysofra/themes/__init__.py +5 -0
  45. pysofra/themes/registry.py +272 -0
  46. pysofra-0.1.0a1.dist-info/METADATA +301 -0
  47. pysofra-0.1.0a1.dist-info/RECORD +50 -0
  48. pysofra-0.1.0a1.dist-info/WHEEL +4 -0
  49. pysofra-0.1.0a1.dist-info/licenses/LICENSE +674 -0
  50. pysofra-0.1.0a1.dist-info/licenses/NOTICE +18 -0
pysofra/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """PySofra — the missing statistical reporting layer for Python.
2
+
3
+ PySofra transforms datasets and statistical model outputs into
4
+ publication-ready tables across HTML, Markdown, DOCX, LaTeX, PPTX, XLSX,
5
+ and PNG. The same underlying :class:`SofraTable` object renders
6
+ beautifully in Jupyter and exports identically to disk.
7
+
8
+ Quick start
9
+ -----------
10
+
11
+ >>> import pandas as pd
12
+ >>> import pysofra as ps
13
+ >>> df = pd.DataFrame({
14
+ ... "age": [55, 62, 47, 68, 51],
15
+ ... "sex": ["F", "M", "F", "M", "F"],
16
+ ... "arm": ["A", "A", "B", "B", "A"],
17
+ ... })
18
+ >>> tbl = (
19
+ ... ps.tbl_one(df, by="arm")
20
+ ... .add_p()
21
+ ... .add_smd()
22
+ ... .theme("clinical")
23
+ ... )
24
+ >>> _ = tbl.to_html()
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from .core.compose import tbl_merge, tbl_stack
30
+ from .core.schema import CellPart
31
+ from .core.table import SofraTable
32
+ from .models.pool import pool
33
+ from .models.regression import tbl_regression
34
+ from .models.survival import tbl_survival
35
+ from .models.uvregression import tbl_uvregression
36
+ from .summary.calibrate import design_effect, post_stratify, rake
37
+ from .summary.design import SurveyDesign
38
+ from .summary.effect_size import (
39
+ auto_effect_size,
40
+ cohen_d,
41
+ cramers_v,
42
+ eta_squared,
43
+ hedges_g,
44
+ omega_squared,
45
+ phi_coefficient,
46
+ )
47
+ from .summary.tbl_cross import tbl_cross
48
+ from .summary.tbl_one import tbl_one
49
+ from .summary.tbl_summary import tbl_summary
50
+ from .summary.tests import available_tests
51
+ from .themes.registry import available_themes, register_theme
52
+
53
+ __version__ = "0.1.0a1"
54
+
55
+ __all__ = [
56
+ "CellPart",
57
+ "SofraTable",
58
+ "SurveyDesign",
59
+ "__version__",
60
+ "auto_effect_size",
61
+ "available_tests",
62
+ "available_themes",
63
+ "cohen_d",
64
+ "cramers_v",
65
+ "design_effect",
66
+ "eta_squared",
67
+ "hedges_g",
68
+ "omega_squared",
69
+ "phi_coefficient",
70
+ "pool",
71
+ "post_stratify",
72
+ "rake",
73
+ "register_theme",
74
+ "tbl_cross",
75
+ "tbl_merge",
76
+ "tbl_one",
77
+ "tbl_regression",
78
+ "tbl_stack",
79
+ "tbl_summary",
80
+ "tbl_survival",
81
+ "tbl_uvregression",
82
+ ]
@@ -0,0 +1,14 @@
1
+ """Core types: :class:`SofraTable` and its schema."""
2
+
3
+ from .schema import Cell, HeaderCell, HeaderRow, Row, SpanningHeader
4
+ from .table import SofraTable, TableSpec
5
+
6
+ __all__ = [
7
+ "Cell",
8
+ "HeaderCell",
9
+ "HeaderRow",
10
+ "Row",
11
+ "SofraTable",
12
+ "SpanningHeader",
13
+ "TableSpec",
14
+ ]
@@ -0,0 +1,167 @@
1
+ """Table composition: :func:`tbl_merge` and :func:`tbl_stack`.
2
+
3
+ Both functions take a sequence of :class:`SofraTable` objects and combine
4
+ them into a single SofraTable. Merging glues tables side-by-side (sharing
5
+ the first / label column by default); stacking concatenates them vertically.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from itertools import zip_longest
11
+
12
+ from .schema import Cell, HeaderCell, HeaderRow, Row, SpanningHeader
13
+ from .table import SofraTable
14
+
15
+
16
+ def tbl_merge(
17
+ tables: list[SofraTable] | tuple[SofraTable, ...],
18
+ *,
19
+ tab_spanners: list[str] | None = None,
20
+ share_first_column: bool = True,
21
+ ) -> SofraTable:
22
+ """Merge tables side-by-side.
23
+
24
+ Parameters
25
+ ----------
26
+ tables
27
+ Two or more :class:`SofraTable` objects with the same number of
28
+ body rows.
29
+ tab_spanners
30
+ Optional list of spanning-header labels, one per input table.
31
+ share_first_column
32
+ When ``True`` (default) and every input has the same first column
33
+ in every row, the duplicate label columns are dropped from the
34
+ 2nd-Nth tables.
35
+ """
36
+ tables = list(tables)
37
+ if len(tables) < 2:
38
+ raise ValueError("tbl_merge requires at least two tables.")
39
+ n_rows_sets = {len(t.rows) for t in tables}
40
+ if len(n_rows_sets) != 1:
41
+ raise ValueError(
42
+ f"tbl_merge requires all tables to have the same number of rows; got {n_rows_sets}."
43
+ )
44
+
45
+ drop_first = (
46
+ share_first_column
47
+ and all(t.rows for t in tables)
48
+ and all(
49
+ all(t.rows[i].cells[0].text == tables[0].rows[i].cells[0].text
50
+ for i in range(len(t.rows)))
51
+ for t in tables[1:]
52
+ )
53
+ )
54
+
55
+ # Headers — pick the deepest header rows across inputs.
56
+ max_header_depth = max(len(t.headers) for t in tables)
57
+ merged_headers: list[HeaderRow] = []
58
+ for level in range(max_header_depth):
59
+ header_row_cells: list[HeaderCell] = []
60
+ for i, t in enumerate(tables):
61
+ hr = t.headers[level] if level < len(t.headers) else None
62
+ if hr is None:
63
+ # pad with empty header cells
64
+ base = tables[0].headers[level] if level < len(tables[0].headers) else None
65
+ width = len(t.headers[0].cells) if t.headers else 1
66
+ hr = HeaderRow(cells=tuple(HeaderCell(text="") for _ in range(width)))
67
+ del base
68
+ row_cells: list[HeaderCell] = list(hr.cells)
69
+ if drop_first and i > 0 and row_cells:
70
+ row_cells = row_cells[1:]
71
+ header_row_cells.extend(row_cells)
72
+ merged_headers.append(HeaderRow(cells=tuple(header_row_cells)))
73
+
74
+ # Spanning headers from tab_spanners (if provided).
75
+ spanning: list[SpanningHeader] = []
76
+ if tab_spanners:
77
+ if len(tab_spanners) != len(tables):
78
+ raise ValueError("tab_spanners must have one entry per table.")
79
+ col = 0
80
+ for i, (t, label) in enumerate(zip(tables, tab_spanners, strict=True)):
81
+ width = len(t.headers[0].cells) if t.headers else len(t.rows[0].cells)
82
+ if drop_first and i > 0:
83
+ width -= 1
84
+ spanning.append(SpanningHeader(label=label, start=col, end=col + width - 1))
85
+ col += width
86
+
87
+ # Body rows — concatenate cells horizontally.
88
+ n_rows = next(iter(n_rows_sets))
89
+ merged_rows: list[Row] = []
90
+ for i in range(n_rows):
91
+ body_cells: list[Cell] = []
92
+ for j, t in enumerate(tables):
93
+ body_row_cells: list[Cell] = list(t.rows[i].cells)
94
+ if drop_first and j > 0 and body_row_cells:
95
+ body_row_cells = body_row_cells[1:]
96
+ body_cells.extend(body_row_cells)
97
+ merged_rows.append(Row(cells=tuple(body_cells),
98
+ is_group_header=tables[0].rows[i].is_group_header))
99
+
100
+ footnotes: list[str] = []
101
+ for t in tables:
102
+ for f in t.footnotes:
103
+ if f not in footnotes:
104
+ footnotes.append(f)
105
+
106
+ return SofraTable(
107
+ rows=tuple(merged_rows),
108
+ headers=tuple(merged_headers),
109
+ spanning_headers=tuple(spanning),
110
+ footnotes=tuple(footnotes),
111
+ caption=tables[0].caption,
112
+ theme_name=tables[0].theme_name,
113
+ metadata={"merged_from": [t.metadata.get("builder", "?") for t in tables]},
114
+ )
115
+
116
+
117
+ def tbl_stack(
118
+ tables: list[SofraTable] | tuple[SofraTable, ...],
119
+ *,
120
+ group_labels: list[str] | None = None,
121
+ ) -> SofraTable:
122
+ """Stack tables vertically.
123
+
124
+ All inputs must share the same column count and header structure.
125
+ Optional ``group_labels`` introduce a group-header row between blocks.
126
+ """
127
+ tables = list(tables)
128
+ if len(tables) < 2:
129
+ raise ValueError("tbl_stack requires at least two tables.")
130
+
131
+ ncols = len(tables[0].headers[0].cells) if tables[0].headers else len(tables[0].rows[0].cells)
132
+ for t in tables[1:]:
133
+ nc = len(t.headers[0].cells) if t.headers else len(t.rows[0].cells)
134
+ if nc != ncols:
135
+ raise ValueError(
136
+ f"tbl_stack requires equal column counts; got {ncols} and {nc}."
137
+ )
138
+ if group_labels is not None and len(group_labels) != len(tables):
139
+ raise ValueError("group_labels must have one entry per table.")
140
+
141
+ rows: list[Row] = []
142
+ for i, t in enumerate(tables):
143
+ if group_labels:
144
+ header_cells = [Cell(text=group_labels[i], bold=True, align="left")]
145
+ header_cells.extend(Cell(text="") for _ in range(ncols - 1))
146
+ rows.append(Row(cells=tuple(header_cells), is_group_header=True))
147
+ rows.extend(t.rows)
148
+
149
+ footnotes: list[str] = []
150
+ for t in tables:
151
+ for f in t.footnotes:
152
+ if f not in footnotes:
153
+ footnotes.append(f)
154
+
155
+ return SofraTable(
156
+ rows=tuple(rows),
157
+ headers=tables[0].headers,
158
+ spanning_headers=tables[0].spanning_headers,
159
+ footnotes=tuple(footnotes),
160
+ caption=tables[0].caption,
161
+ theme_name=tables[0].theme_name,
162
+ metadata={"stacked_from": [t.metadata.get("builder", "?") for t in tables]},
163
+ )
164
+
165
+
166
+ # Silence unused
167
+ _ = zip_longest
pysofra/core/format.py ADDED
@@ -0,0 +1,155 @@
1
+ """Formatting helpers for numbers, p-values, percents, and confidence intervals.
2
+
3
+ These are deterministic, locale-agnostic, and unit-testable. All rounding
4
+ uses banker's-rounding-free conventional half-up via Python's ``format`` mini
5
+ language so output matches what most statistical journals expect.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from typing import Final
12
+
13
+ NA_STRING: Final[str] = "—"
14
+
15
+
16
+ def fmt_number(value: float | int | None, digits: int = 2) -> str:
17
+ """Format a numeric value to ``digits`` decimal places.
18
+
19
+ ``None``, ``NaN``, infinite, and anything that can't be coerced to a
20
+ ``float`` render as :data:`NA_STRING`. Both IEEE-754 negative zero
21
+ AND small negative numbers that round to all-zero at ``digits``
22
+ precision are normalised so cells never display as ``"-0.00"``
23
+ (which is confusing and uninformative).
24
+ """
25
+ if value is None:
26
+ return NA_STRING
27
+ try:
28
+ v = float(value)
29
+ except (TypeError, ValueError):
30
+ return NA_STRING
31
+ if math.isnan(v) or math.isinf(v):
32
+ return NA_STRING
33
+ out = f"{v:.{digits}f}"
34
+ # If the formatted result is a "negative zero" representation —
35
+ # leading minus on a string of zeros and a decimal point — drop
36
+ # the sign. Covers both IEEE -0.0 (renders as "-0.00") and small
37
+ # negative inputs that round to zero at this precision (e.g.
38
+ # -0.001 at 2dp renders as "-0.00"). The information loss is
39
+ # already in the round-to-2dp step; preserving the sign on what
40
+ # the reader sees as zero would be misleading.
41
+ if out.startswith("-") and set(out[1:]) <= {"0", "."}:
42
+ out = out[1:]
43
+ return out
44
+
45
+
46
+ def fmt_int(value: float | int | None) -> str:
47
+ if value is None:
48
+ return NA_STRING
49
+ try:
50
+ v = float(value)
51
+ except (TypeError, ValueError):
52
+ return NA_STRING
53
+ if math.isnan(v) or math.isinf(v):
54
+ return NA_STRING
55
+ return f"{int(round(v))}"
56
+
57
+
58
+ def fmt_percent(value: float | None, digits: int = 1) -> str:
59
+ """Format a fraction (0–1) as a percent. Pass 0.234 → '23.4'.
60
+
61
+ Negative-zero output ("-0.0") is normalised to "0.0" for the same
62
+ reason :func:`fmt_number` does (it's confusing in publication tables).
63
+ """
64
+ if value is None:
65
+ return NA_STRING
66
+ if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
67
+ return NA_STRING
68
+ out = f"{100.0 * float(value):.{digits}f}"
69
+ if out.startswith("-") and set(out[1:]) <= {"0", "."}:
70
+ out = out[1:]
71
+ return out
72
+
73
+
74
+ def fmt_n_pct(n: int, total: int, digits: int = 1) -> str:
75
+ """Render ``n (xx.x%)``. If ``total`` is zero, returns ``n (—)``."""
76
+ if total <= 0:
77
+ return f"{int(n)} ({NA_STRING})"
78
+ pct = 100.0 * n / total
79
+ return f"{int(n)} ({pct:.{digits}f}%)"
80
+
81
+
82
+ def fmt_mean_sd(mean: float | None, sd: float | None, digits: int = 2) -> str:
83
+ """Render ``mean (sd)`` in journal style."""
84
+ return f"{fmt_number(mean, digits)} ({fmt_number(sd, digits)})"
85
+
86
+
87
+ def fmt_median_iqr(
88
+ median: float | None,
89
+ q1: float | None,
90
+ q3: float | None,
91
+ digits: int = 2,
92
+ ) -> str:
93
+ """Render ``median (Q1, Q3)``."""
94
+ return f"{fmt_number(median, digits)} ({fmt_number(q1, digits)}, {fmt_number(q3, digits)})"
95
+
96
+
97
+ def fmt_range(lo: float | None, hi: float | None, digits: int = 2) -> str:
98
+ return f"{fmt_number(lo, digits)}, {fmt_number(hi, digits)}"
99
+
100
+
101
+ def fmt_ci(
102
+ lo: float | None,
103
+ hi: float | None,
104
+ digits: int = 2,
105
+ *,
106
+ sep: str = ", ",
107
+ ) -> str:
108
+ """Render a confidence interval as ``lo, hi``."""
109
+ return f"{fmt_number(lo, digits)}{sep}{fmt_number(hi, digits)}"
110
+
111
+
112
+ def fmt_estimate_ci(
113
+ estimate: float | None,
114
+ lo: float | None,
115
+ hi: float | None,
116
+ digits: int = 2,
117
+ ) -> str:
118
+ """Render ``estimate (lo, hi)``."""
119
+ return f"{fmt_number(estimate, digits)} ({fmt_ci(lo, hi, digits)})"
120
+
121
+
122
+ def fmt_p_value(p: float | None, digits: int = 3) -> str:
123
+ """Journal-style p-value formatting.
124
+
125
+ Rules:
126
+ * ``None`` / ``NaN`` / infinite → :data:`NA_STRING`
127
+ * out-of-range (``p < 0`` or ``p > 1``) → :data:`NA_STRING`
128
+ (silently coercing an invalid p-value would mask a real bug in
129
+ the upstream computation)
130
+ * ``p < 10^-digits`` → ``"<0.001"`` (for ``digits=3``)
131
+ * ``p > 0.99`` → ``">0.99"``
132
+ * otherwise → ``"0.xxx"``
133
+ """
134
+ if p is None:
135
+ return NA_STRING
136
+ if isinstance(p, float) and (math.isnan(p) or math.isinf(p)):
137
+ return NA_STRING
138
+ p = float(p)
139
+ if p < 0.0 or p > 1.0:
140
+ return NA_STRING
141
+ threshold = 10 ** (-digits)
142
+ if p < threshold:
143
+ return f"<{threshold:.{digits}f}"
144
+ if p > 0.99:
145
+ return ">0.99"
146
+ return f"{p:.{digits}f}"
147
+
148
+
149
+ def fmt_smd(smd: float | None, digits: int = 3) -> str:
150
+ """Format a standardized mean difference. Always signed magnitude."""
151
+ if smd is None:
152
+ return NA_STRING
153
+ if isinstance(smd, float) and (math.isnan(smd) or math.isinf(smd)):
154
+ return NA_STRING
155
+ return f"{float(smd):.{digits}f}"
pysofra/core/frames.py ADDED
@@ -0,0 +1,69 @@
1
+ """DataFrame adaptation.
2
+
3
+ PySofra's public API accepts any object with the pandas DataFrame shape —
4
+ ``pandas.DataFrame``, ``polars.DataFrame``, or ``polars.LazyFrame``. The
5
+ adapter in this module normalises the input to pandas internally so the
6
+ statistical engines have one type to reason about.
7
+
8
+ We keep the dependency on polars *optional* — importing this module does
9
+ not require polars to be installed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ import pandas as pd
17
+
18
+
19
+ def to_pandas(data: Any) -> pd.DataFrame:
20
+ """Convert a DataFrame-like input to a pandas DataFrame.
21
+
22
+ Accepted inputs:
23
+
24
+ * ``pandas.DataFrame`` — returned as-is (no copy).
25
+ * ``polars.DataFrame`` — converted via ``.to_pandas()``.
26
+ * ``polars.LazyFrame`` — collected first, then converted.
27
+ * Any object exposing ``.to_pandas()`` — invoked and validated.
28
+
29
+ Raises ``TypeError`` for unrecognised inputs.
30
+ """
31
+ if isinstance(data, pd.DataFrame):
32
+ return data
33
+
34
+ # Duck-typed polars detection — don't import polars unless we see it.
35
+ cls = type(data)
36
+ qualname = f"{cls.__module__}.{cls.__name__}"
37
+ if qualname.startswith("polars."):
38
+ # LazyFrame needs an explicit ``.collect()`` first.
39
+ if cls.__name__ == "LazyFrame":
40
+ data = data.collect()
41
+ try:
42
+ pandas_df = data.to_pandas()
43
+ except (ImportError, ModuleNotFoundError): # pragma: no cover
44
+ # ``polars.DataFrame.to_pandas`` routes through pyarrow by
45
+ # default. The ``pysofra[polars]`` and ``pysofra[all]``
46
+ # extras now declare ``pyarrow``, so this fallback is
47
+ # exercised only by users who hand-pin polars without it.
48
+ # Falls back to a column-wise conversion that needs only
49
+ # the standard library + pandas.
50
+ pandas_df = pd.DataFrame(
51
+ {col: data[col].to_list() for col in data.columns}
52
+ )
53
+ if not isinstance(pandas_df, pd.DataFrame): # pragma: no cover
54
+ raise TypeError(
55
+ "polars to_pandas() did not return a pandas DataFrame; "
56
+ f"got {type(pandas_df).__name__}."
57
+ )
58
+ return pandas_df
59
+
60
+ # Generic fallback: any object that knows how to give us pandas.
61
+ if hasattr(data, "to_pandas"):
62
+ result = data.to_pandas()
63
+ if isinstance(result, pd.DataFrame):
64
+ return result
65
+
66
+ raise TypeError(
67
+ f"Unsupported DataFrame type {qualname!r}. "
68
+ "PySofra accepts pandas.DataFrame and polars.DataFrame / LazyFrame."
69
+ )
pysofra/core/schema.py ADDED
@@ -0,0 +1,128 @@
1
+ """Internal schema for SofraTable.
2
+
3
+ This module defines the backend-agnostic representation of a statistical
4
+ table. Every renderer (HTML, Markdown, DOCX, PPTX, LaTeX) consumes the same
5
+ schema; statistical engines (Table 1, summary, regression) produce it.
6
+
7
+ The schema is intentionally simple and immutable. Cells carry both the
8
+ *display* string and the *raw* value when known, so renderers can choose
9
+ the appropriate format (e.g., right-alignment for numeric cells).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Literal
16
+
17
+ CellKind = Literal[
18
+ "text", "numeric", "p_value", "q_value", "ci", "header_label", "group_label"
19
+ ]
20
+ Alignment = Literal["left", "center", "right"]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class CellPart:
25
+ """A typographically distinct run inside a single cell.
26
+
27
+ Used by ``SofraTable.compose()`` to embed multi-format content
28
+ (bold + italic + colour) inside one cell. Renderers honour
29
+ ``CellPart.bold``, ``italic``, ``superscript``, ``subscript``,
30
+ ``code``, ``color``, and ``link`` where the backend supports them;
31
+ unsupported flags degrade to plain text.
32
+ """
33
+
34
+ text: str
35
+ bold: bool = False
36
+ italic: bool = False
37
+ superscript: bool = False
38
+ subscript: bool = False
39
+ code: bool = False
40
+ color: str | None = None
41
+ link: str | None = None
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Cell:
46
+ """One cell of a SofraTable.
47
+
48
+ `value` carries the raw underlying value when meaningful (e.g., a float
49
+ p-value). `text` is the rendered string used by all backends. Renderers
50
+ may consult `kind` and `value` for additional formatting decisions.
51
+
52
+ `style` is an optional mapping of renderer-specific overrides:
53
+
54
+ - ``html`` — extra inline-CSS declarations applied to the ``<td>``.
55
+ - ``docx`` — keys like ``padding_pt``, ``shading_hex``, ``borders``.
56
+ - ``xlsx`` — keys like ``bg_color``, ``num_format`` (forwarded to xlsxwriter).
57
+ - ``latex`` — prepended raw LaTeX (e.g. ``\\rowcolor{...}``).
58
+
59
+ All renderers ignore keys they don't understand.
60
+ """
61
+
62
+ text: str
63
+ value: Any = None
64
+ kind: CellKind = "text"
65
+ align: Alignment | None = None
66
+ bold: bool = False
67
+ italic: bool = False
68
+ indent: int = 0
69
+ style: dict[str, Any] | None = None
70
+ parts: tuple[CellPart, ...] | None = None
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Row:
75
+ """One row of the table body."""
76
+
77
+ cells: tuple[Cell, ...]
78
+ is_group_header: bool = False
79
+ metadata: dict[str, Any] = field(default_factory=dict)
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class HeaderCell:
84
+ text: str
85
+ align: Alignment = "center"
86
+ bold: bool = True
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class HeaderRow:
91
+ """A column-header row. Tables may have multiple stacked header rows."""
92
+
93
+ cells: tuple[HeaderCell, ...]
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class SpanningHeader:
98
+ """A spanning header above the column headers.
99
+
100
+ `start` and `end` are 0-indexed column indices, inclusive.
101
+ """
102
+
103
+ label: str
104
+ start: int
105
+ end: int
106
+
107
+
108
+ def make_cell(
109
+ text: str,
110
+ value: Any = None,
111
+ kind: CellKind = "text",
112
+ align: Alignment | None = None,
113
+ bold: bool = False,
114
+ italic: bool = False,
115
+ indent: int = 0,
116
+ style: dict[str, Any] | None = None,
117
+ ) -> Cell:
118
+ """Convenience constructor used internally."""
119
+ return Cell(
120
+ text=text,
121
+ value=value,
122
+ kind=kind,
123
+ align=align,
124
+ bold=bold,
125
+ italic=italic,
126
+ indent=indent,
127
+ style=style,
128
+ )