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.
- pysofra/__init__.py +82 -0
- pysofra/core/__init__.py +14 -0
- pysofra/core/compose.py +167 -0
- pysofra/core/format.py +155 -0
- pysofra/core/frames.py +69 -0
- pysofra/core/schema.py +128 -0
- pysofra/core/table.py +924 -0
- pysofra/io/__init__.py +1 -0
- pysofra/models/__init__.py +6 -0
- pysofra/models/extract.py +249 -0
- pysofra/models/pool.py +119 -0
- pysofra/models/regression.py +507 -0
- pysofra/models/survival.py +395 -0
- pysofra/models/uvregression.py +438 -0
- pysofra/notebook/__init__.py +6 -0
- pysofra/plot/__init__.py +23 -0
- pysofra/plot/_backend.py +32 -0
- pysofra/plot/forest.py +159 -0
- pysofra/plot/inline.py +171 -0
- pysofra/plot/km.py +249 -0
- pysofra/render/__init__.py +28 -0
- pysofra/render/_zip_determinism.py +57 -0
- pysofra/render/base.py +22 -0
- pysofra/render/docx.py +286 -0
- pysofra/render/html.py +442 -0
- pysofra/render/image.py +130 -0
- pysofra/render/latex.py +253 -0
- pysofra/render/markdown.py +128 -0
- pysofra/render/pptx.py +340 -0
- pysofra/render/xlsx.py +226 -0
- pysofra/summary/__init__.py +6 -0
- pysofra/summary/calibrate.py +214 -0
- pysofra/summary/design.py +246 -0
- pysofra/summary/effect_size.py +187 -0
- pysofra/summary/extras.py +745 -0
- pysofra/summary/smd.py +133 -0
- pysofra/summary/stats.py +135 -0
- pysofra/summary/tbl_cross.py +339 -0
- pysofra/summary/tbl_one.py +1220 -0
- pysofra/summary/tbl_summary.py +51 -0
- pysofra/summary/tests.py +370 -0
- pysofra/summary/typing.py +129 -0
- pysofra/summary/weights.py +161 -0
- pysofra/themes/__init__.py +5 -0
- pysofra/themes/registry.py +272 -0
- pysofra-0.1.0a1.dist-info/METADATA +301 -0
- pysofra-0.1.0a1.dist-info/RECORD +50 -0
- pysofra-0.1.0a1.dist-info/WHEEL +4 -0
- pysofra-0.1.0a1.dist-info/licenses/LICENSE +674 -0
- 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)
|