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/render/docx.py ADDED
@@ -0,0 +1,286 @@
1
+ """DOCX rendering via ``python-docx``.
2
+
3
+ The renderer abstracts away every low-level python-docx call. Callers only
4
+ ever invoke :meth:`~pysofra.core.SofraTable.to_docx`; they never need to
5
+ build paragraphs, runs, or XML cells themselves.
6
+
7
+ Theme hints come from the active theme's ``docx`` dict (font, sizing,
8
+ header borders, outer borders, zebra striping).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from ..core.schema import HeaderRow, Row, SpanningHeader
18
+ from ..core.table import SofraTable
19
+ from ..themes.registry import resolve_theme
20
+
21
+
22
+ @dataclass
23
+ class DocxRenderer:
24
+ """Write a SofraTable to a ``.docx`` file."""
25
+
26
+ def write(self, table: SofraTable, path: Path) -> Path:
27
+ try:
28
+ from docx import Document
29
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
30
+ from docx.oxml import OxmlElement
31
+ from docx.oxml.ns import qn
32
+ from docx.shared import Pt
33
+ except ImportError as e: # pragma: no cover
34
+ raise ImportError(
35
+ "DOCX export requires python-docx. Install with `pip install python-docx`."
36
+ ) from e
37
+
38
+ theme = resolve_theme(table.theme_name)
39
+ d = theme.docx
40
+ font_name: str = d.get("font_name", "Calibri")
41
+ font_size: int = int(d.get("font_size", 10))
42
+ header_bold: bool = bool(d.get("header_bold", True))
43
+ header_bottom_border: bool = bool(d.get("header_bottom_border", True))
44
+ outer_border: bool = bool(d.get("outer_border", True))
45
+ row_zebra: bool = bool(d.get("row_zebra", False))
46
+
47
+ doc = Document()
48
+ if table.caption:
49
+ cap = doc.add_paragraph()
50
+ cap.alignment = WD_ALIGN_PARAGRAPH.LEFT
51
+ run = cap.add_run(table.caption)
52
+ run.bold = True
53
+ run.font.name = font_name
54
+ run.font.size = Pt(font_size + 1)
55
+
56
+ plot = getattr(table, "inline_plot", None)
57
+ if plot is not None and getattr(plot, "png_bytes", None) and \
58
+ table.inline_svg_position == "above":
59
+ _embed_png(doc, plot.png_bytes, plot.width_in)
60
+
61
+ ncols = _ncols(table)
62
+ n_header_rows = (1 if table.spanning_headers else 0) + len(table.headers)
63
+ n_body_rows = len(table.rows)
64
+ n_total_rows = n_header_rows + n_body_rows
65
+ if n_total_rows == 0:
66
+ n_total_rows = 1
67
+
68
+ word_table = doc.add_table(rows=n_total_rows, cols=ncols)
69
+ # Default is autofit-on; users can disable via .autofit(False).
70
+ word_table.autofit = bool((table.metadata or {}).get("autofit", True))
71
+ word_table.style = "Table Grid" if outer_border else "Normal Table"
72
+
73
+ row_idx = 0
74
+ if table.spanning_headers:
75
+ _write_spanning_row(
76
+ word_table, row_idx, table.spanning_headers, ncols,
77
+ font_name=font_name, font_size=font_size, qn=qn,
78
+ OxmlElement=OxmlElement, header_bold=header_bold,
79
+ )
80
+ row_idx += 1
81
+
82
+ for hr in table.headers:
83
+ _write_header_row(
84
+ word_table, row_idx, hr, ncols,
85
+ font_name=font_name, font_size=font_size,
86
+ header_bold=header_bold,
87
+ header_bottom_border=header_bottom_border and hr is table.headers[-1],
88
+ qn=qn, OxmlElement=OxmlElement,
89
+ )
90
+ row_idx += 1
91
+
92
+ for r_idx, body_row in enumerate(table.rows):
93
+ zebra = row_zebra and (r_idx % 2 == 1)
94
+ _write_body_row(
95
+ word_table, row_idx, body_row, ncols,
96
+ font_name=font_name, font_size=font_size,
97
+ zebra=zebra, qn=qn, OxmlElement=OxmlElement,
98
+ )
99
+ row_idx += 1
100
+
101
+ if plot is not None and getattr(plot, "png_bytes", None) and \
102
+ table.inline_svg_position == "below":
103
+ _embed_png(doc, plot.png_bytes, plot.width_in)
104
+
105
+ if table.footnotes:
106
+ for footnote in table.footnotes:
107
+ para = doc.add_paragraph()
108
+ run = para.add_run(footnote)
109
+ run.italic = True
110
+ run.font.name = font_name
111
+ run.font.size = Pt(max(8, font_size - 1))
112
+
113
+ path = Path(path)
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ doc.save(str(path))
116
+ # python-docx stamps every ZIP entry with the current wall-clock,
117
+ # which breaks cross-process byte-determinism. Rewrite with fixed
118
+ # entry mtimes so identical input always yields identical bytes.
119
+ from ._zip_determinism import make_zip_deterministic
120
+ make_zip_deterministic(path)
121
+ return path
122
+
123
+ # Renderer interface: render() returns the path string. Kept for
124
+ # symmetry with the other renderers; most callers use to_docx().
125
+ def render(self, table: SofraTable) -> str: # pragma: no cover
126
+ raise NotImplementedError("DocxRenderer writes to disk; use .write(table, path).")
127
+
128
+
129
+ # ----------------------------------------------------------------------
130
+ # Helpers
131
+ # ----------------------------------------------------------------------
132
+
133
+
134
+ def _ncols(table: SofraTable) -> int:
135
+ if table.headers:
136
+ return len(table.headers[0].cells)
137
+ if table.rows:
138
+ return len(table.rows[0].cells)
139
+ return 1
140
+
141
+
142
+ def _embed_png(doc: Any, png_bytes: bytes, width_in: float) -> None:
143
+ """Insert a PNG image at the current insertion point."""
144
+ import io
145
+
146
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
147
+ from docx.shared import Inches
148
+
149
+ para = doc.add_paragraph()
150
+ para.alignment = WD_ALIGN_PARAGRAPH.CENTER
151
+ run = para.add_run()
152
+ run.add_picture(io.BytesIO(png_bytes), width=Inches(width_in))
153
+
154
+
155
+ def _set_cell_text(cell: Any, text: str, *, bold: bool, italic: bool,
156
+ font_name: str, font_size: int, align: str | None,
157
+ indent_em: float = 0.0,
158
+ parts: Any = None) -> None:
159
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
160
+ from docx.shared import Pt
161
+
162
+ cell.text = "" # clear default empty paragraph contents
163
+ para = cell.paragraphs[0]
164
+ if align == "right":
165
+ para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
166
+ elif align == "center":
167
+ para.alignment = WD_ALIGN_PARAGRAPH.CENTER
168
+ else:
169
+ para.alignment = WD_ALIGN_PARAGRAPH.LEFT
170
+ if indent_em > 0:
171
+ from docx.shared import Pt as _Pt # noqa: F401
172
+ para.paragraph_format.left_indent = Pt(indent_em * 12)
173
+ if parts:
174
+ # One run per CellPart, each carrying its own formatting.
175
+ for p in parts:
176
+ run = para.add_run(p.text)
177
+ run.bold = bool(p.bold) or bold
178
+ run.italic = bool(p.italic) or italic
179
+ run.font.name = font_name
180
+ run.font.size = Pt(font_size)
181
+ if p.superscript:
182
+ run.font.superscript = True
183
+ if p.subscript:
184
+ run.font.subscript = True
185
+ if p.code:
186
+ run.font.name = "Courier New"
187
+ if p.color:
188
+ try:
189
+ from docx.shared import RGBColor
190
+ run.font.color.rgb = RGBColor.from_string(
191
+ p.color.lstrip("#")
192
+ )
193
+ except Exception: # pragma: no cover — bad colour string
194
+ pass
195
+ return
196
+ run = para.add_run(text)
197
+ run.bold = bold
198
+ run.italic = italic
199
+ run.font.name = font_name
200
+ run.font.size = Pt(font_size)
201
+
202
+
203
+ def _write_header_row(word_table: Any, idx: int, hr: HeaderRow, ncols: int,
204
+ *, font_name: str, font_size: int,
205
+ header_bold: bool, header_bottom_border: bool,
206
+ qn: Any, OxmlElement: Any) -> None:
207
+ row = word_table.rows[idx]
208
+ for j, cell in enumerate(hr.cells[:ncols]):
209
+ wc = row.cells[j]
210
+ _set_cell_text(
211
+ wc,
212
+ cell.text.replace("\n", "\n"),
213
+ bold=header_bold or cell.bold,
214
+ italic=False,
215
+ font_name=font_name,
216
+ font_size=font_size,
217
+ align=cell.align,
218
+ )
219
+ if header_bottom_border:
220
+ _set_cell_borders(wc, bottom="single", qn=qn, OxmlElement=OxmlElement, size=8)
221
+
222
+
223
+ def _write_body_row(word_table: Any, idx: int, r: Row, ncols: int,
224
+ *, font_name: str, font_size: int, zebra: bool,
225
+ qn: Any, OxmlElement: Any) -> None:
226
+ row = word_table.rows[idx]
227
+ for j, c in enumerate(r.cells[:ncols]):
228
+ wc = row.cells[j]
229
+ _set_cell_text(
230
+ wc, c.text,
231
+ bold=c.bold or r.is_group_header,
232
+ italic=c.italic,
233
+ font_name=font_name,
234
+ font_size=font_size,
235
+ align=c.align,
236
+ indent_em=c.indent * 1.2,
237
+ parts=c.parts,
238
+ )
239
+ if zebra:
240
+ _set_cell_shading(wc, "F2F2F2", qn=qn, OxmlElement=OxmlElement)
241
+
242
+
243
+ def _write_spanning_row(word_table: Any, idx: int, spans: tuple[SpanningHeader, ...],
244
+ ncols: int, *, font_name: str, font_size: int,
245
+ qn: Any, OxmlElement: Any, header_bold: bool) -> None:
246
+ row = word_table.rows[idx]
247
+ # Set labels and merge.
248
+ for span in spans:
249
+ anchor = row.cells[span.start]
250
+ for j in range(span.start + 1, span.end + 1):
251
+ anchor = anchor.merge(row.cells[j])
252
+ _set_cell_text(
253
+ anchor, span.label,
254
+ bold=header_bold, italic=False,
255
+ font_name=font_name, font_size=font_size, align="center",
256
+ )
257
+ _set_cell_borders(anchor, bottom="single", qn=qn, OxmlElement=OxmlElement, size=4)
258
+
259
+
260
+ def _set_cell_borders(cell: Any, *, bottom: str | None = None, top: str | None = None,
261
+ size: int = 4, qn: Any, OxmlElement: Any) -> None:
262
+ tc_pr = cell._tc.get_or_add_tcPr()
263
+ borders = tc_pr.find(qn("w:tcBorders"))
264
+ if borders is None:
265
+ borders = OxmlElement("w:tcBorders")
266
+ tc_pr.append(borders)
267
+ for edge, style in (("bottom", bottom), ("top", top)):
268
+ if style is None:
269
+ continue
270
+ existing = borders.find(qn(f"w:{edge}"))
271
+ if existing is not None:
272
+ borders.remove(existing)
273
+ el = OxmlElement(f"w:{edge}")
274
+ el.set(qn("w:val"), style)
275
+ el.set(qn("w:sz"), str(size))
276
+ el.set(qn("w:color"), "000000")
277
+ borders.append(el)
278
+
279
+
280
+ def _set_cell_shading(cell: Any, color_hex: str, *, qn: Any, OxmlElement: Any) -> None:
281
+ tc_pr = cell._tc.get_or_add_tcPr()
282
+ shd = OxmlElement("w:shd")
283
+ shd.set(qn("w:val"), "clear")
284
+ shd.set(qn("w:color"), "auto")
285
+ shd.set(qn("w:fill"), color_hex)
286
+ tc_pr.append(shd)