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/html.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""HTML rendering, including the rich ``_repr_html_`` notebook output.
|
|
2
|
+
|
|
3
|
+
The HTML renderer emits a self-contained fragment:
|
|
4
|
+
|
|
5
|
+
* a unique wrapper class so styles do not leak across multiple tables in
|
|
6
|
+
the same notebook;
|
|
7
|
+
* a scoped ``<style>`` block built from the active theme;
|
|
8
|
+
* every theme-driven structural style (padding, borders, font) ALSO
|
|
9
|
+
emitted as an inline ``style="..."`` attribute on the affected
|
|
10
|
+
element, so themes stay visibly distinct even in renderers that
|
|
11
|
+
sanitize away inline ``<style>`` blocks (GitHub's notebook viewer,
|
|
12
|
+
some markdown previewers, some PDF/email clients).
|
|
13
|
+
|
|
14
|
+
Notebook mode and standalone mode produce the same HTML; only a wrapper
|
|
15
|
+
``<div class="pysofra-wrap">`` is added in notebook mode so the table can
|
|
16
|
+
scroll horizontally inside narrow output cells.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import html
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
from ..core.schema import Cell, HeaderCell, HeaderRow, Row, SpanningHeader
|
|
26
|
+
from ..core.table import SofraTable
|
|
27
|
+
from ..themes.registry import Theme, resolve_theme
|
|
28
|
+
from .base import Renderer
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _scope_id_for(table: SofraTable) -> str:
|
|
32
|
+
"""Stable per-content scope id.
|
|
33
|
+
|
|
34
|
+
Derived from the table's textual content (rows, headers, spans,
|
|
35
|
+
caption, footnotes, theme) so that re-rendering the same table —
|
|
36
|
+
in the same or a different process — always produces an identical
|
|
37
|
+
HTML string. This is required for deterministic snapshot tests
|
|
38
|
+
and reproducible publications.
|
|
39
|
+
"""
|
|
40
|
+
h = hashlib.sha256()
|
|
41
|
+
h.update((table.theme_name or "").encode("utf-8"))
|
|
42
|
+
h.update((table.caption or "").encode("utf-8"))
|
|
43
|
+
for fn in table.footnotes:
|
|
44
|
+
h.update(b"\x00f")
|
|
45
|
+
h.update(fn.encode("utf-8"))
|
|
46
|
+
for hr in table.headers:
|
|
47
|
+
h.update(b"\x00h")
|
|
48
|
+
for hc in hr.cells:
|
|
49
|
+
h.update(hc.text.encode("utf-8"))
|
|
50
|
+
for s in table.spanning_headers:
|
|
51
|
+
h.update(f"\x00s{s.label}|{s.start}|{s.end}".encode())
|
|
52
|
+
for r in table.rows:
|
|
53
|
+
h.update(b"\x00r")
|
|
54
|
+
for rc in r.cells:
|
|
55
|
+
h.update(rc.text.encode("utf-8"))
|
|
56
|
+
return f"pysofra-{h.hexdigest()[:10]}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ----------------------------------------------------------------------
|
|
60
|
+
# Inline-style precomputation
|
|
61
|
+
# ----------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
# Properties safe to inline on <table>: they all cascade via CSS
|
|
64
|
+
# inheritance to descendant <th> and <td>, so we get themed text
|
|
65
|
+
# without bloating every cell with its own style attribute.
|
|
66
|
+
_INHERITABLE_TABLE_PROPS = (
|
|
67
|
+
"font-family",
|
|
68
|
+
"font-size",
|
|
69
|
+
"line-height",
|
|
70
|
+
"color",
|
|
71
|
+
"border-collapse",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Per-element structural properties that DO NOT cascade. These get
|
|
75
|
+
# inlined onto each affected element so they survive <style>-block
|
|
76
|
+
# stripping by sanitizers. The lists are deliberately conservative —
|
|
77
|
+
# only the visually significant axes that themes actually vary.
|
|
78
|
+
_TH_INLINE_PROPS = (
|
|
79
|
+
"padding",
|
|
80
|
+
"border-top",
|
|
81
|
+
"border-bottom",
|
|
82
|
+
"background",
|
|
83
|
+
"text-align",
|
|
84
|
+
"vertical-align",
|
|
85
|
+
)
|
|
86
|
+
_TD_INLINE_PROPS = (
|
|
87
|
+
"padding",
|
|
88
|
+
"border-bottom",
|
|
89
|
+
"vertical-align",
|
|
90
|
+
)
|
|
91
|
+
_LAST_ROW_TD_INLINE_PROPS = (
|
|
92
|
+
"border-bottom",
|
|
93
|
+
)
|
|
94
|
+
_CAPTION_INLINE_PROPS = (
|
|
95
|
+
"font-family",
|
|
96
|
+
"font-size",
|
|
97
|
+
"font-weight",
|
|
98
|
+
"font-style",
|
|
99
|
+
"padding",
|
|
100
|
+
"text-align",
|
|
101
|
+
)
|
|
102
|
+
_TFOOT_TD_INLINE_PROPS = (
|
|
103
|
+
"font-family",
|
|
104
|
+
"font-size",
|
|
105
|
+
"color",
|
|
106
|
+
"padding-top",
|
|
107
|
+
"border-bottom",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _css_to_inline(decls: dict[str, str], props: tuple[str, ...]) -> str:
|
|
112
|
+
"""Serialise selected declarations as an inline ``style`` value.
|
|
113
|
+
|
|
114
|
+
Filters to ``props`` (in order), and rewrites any double-quoted
|
|
115
|
+
CSS string literals — e.g. ``font-family: "Helvetica Neue", ...`` —
|
|
116
|
+
to use single quotes, so the value is safe to drop inside a
|
|
117
|
+
double-quoted HTML attribute. Single quotes are valid CSS string
|
|
118
|
+
delimiters, so the rendered font lookup is unchanged.
|
|
119
|
+
"""
|
|
120
|
+
parts: list[str] = []
|
|
121
|
+
for p in props:
|
|
122
|
+
if p in decls:
|
|
123
|
+
parts.append(f"{p}:{decls[p].replace(chr(34), chr(39))}")
|
|
124
|
+
return ";".join(parts)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class _ThemeInlines:
|
|
129
|
+
"""Pre-computed inline-style strings for every themed region.
|
|
130
|
+
|
|
131
|
+
Built once per ``render()`` call so the per-cell formatting loop
|
|
132
|
+
is a simple string append rather than a dict lookup + filter on
|
|
133
|
+
every cell.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
table: str
|
|
137
|
+
th: str
|
|
138
|
+
td: str
|
|
139
|
+
last_row_td: str
|
|
140
|
+
caption: str
|
|
141
|
+
tfoot_td: str
|
|
142
|
+
spanning: str
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _theme_inlines(theme: Theme) -> _ThemeInlines:
|
|
146
|
+
"""Pre-compute inline style strings for each table region."""
|
|
147
|
+
css = theme.css
|
|
148
|
+
return _ThemeInlines(
|
|
149
|
+
table=_css_to_inline(css.get("table", {}), _INHERITABLE_TABLE_PROPS),
|
|
150
|
+
th=_css_to_inline(css.get("th", {}), _TH_INLINE_PROPS),
|
|
151
|
+
td=_css_to_inline(css.get("td", {}), _TD_INLINE_PROPS),
|
|
152
|
+
last_row_td=_css_to_inline(
|
|
153
|
+
css.get("tr:last-child td", {}), _LAST_ROW_TD_INLINE_PROPS,
|
|
154
|
+
),
|
|
155
|
+
caption=_css_to_inline(css.get("caption", {}), _CAPTION_INLINE_PROPS),
|
|
156
|
+
tfoot_td=_css_to_inline(css.get("tfoot td", {}), _TFOOT_TD_INLINE_PROPS),
|
|
157
|
+
spanning=_css_to_inline(
|
|
158
|
+
css.get(".pysofra-spanning", {}),
|
|
159
|
+
("padding", "border-bottom", "text-align", "font-weight"),
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class HtmlRenderer(Renderer[str]):
|
|
166
|
+
notebook: bool = False
|
|
167
|
+
sticky_header: bool = False
|
|
168
|
+
max_height: str | None = None # CSS length, e.g. "60vh"; enables vertical scroll
|
|
169
|
+
|
|
170
|
+
def render(self, table: SofraTable) -> str:
|
|
171
|
+
theme = resolve_theme(table.theme_name)
|
|
172
|
+
scope_id = _scope_id_for(table)
|
|
173
|
+
style = _build_style(theme, scope_id, sticky=self.sticky_header)
|
|
174
|
+
inlines = _theme_inlines(theme)
|
|
175
|
+
|
|
176
|
+
head = _render_caption(table, inlines) + _render_thead(table, inlines)
|
|
177
|
+
body = _render_tbody(table, inlines)
|
|
178
|
+
foot = _render_tfoot(table, inlines)
|
|
179
|
+
ncols = _ncols(table)
|
|
180
|
+
|
|
181
|
+
table_html = (
|
|
182
|
+
f'<table class="pysofra {scope_id}" role="table" style="{inlines.table}">'
|
|
183
|
+
f"{head}{body}{foot}"
|
|
184
|
+
"</table>"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
wrap_styles: list[str] = []
|
|
188
|
+
if self.notebook or self.max_height:
|
|
189
|
+
wrap_styles.append("overflow-x:auto")
|
|
190
|
+
wrap_styles.append("max-width:100%")
|
|
191
|
+
if self.max_height:
|
|
192
|
+
wrap_styles.append(f"max-height:{self.max_height}")
|
|
193
|
+
wrap_styles.append("overflow-y:auto")
|
|
194
|
+
if wrap_styles:
|
|
195
|
+
wrapper = (
|
|
196
|
+
f'<div class="pysofra-wrap {scope_id}" '
|
|
197
|
+
f'style="{";".join(wrap_styles)};">'
|
|
198
|
+
f"{table_html}</div>"
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
wrapper = table_html
|
|
202
|
+
|
|
203
|
+
if table.inline_svg:
|
|
204
|
+
svg_block = (
|
|
205
|
+
f'<div class="pysofra-plot {scope_id}" '
|
|
206
|
+
f'style="max-width:100%;">{table.inline_svg}</div>'
|
|
207
|
+
)
|
|
208
|
+
if table.inline_svg_position == "below":
|
|
209
|
+
wrapper = f"{wrapper}{svg_block}"
|
|
210
|
+
else:
|
|
211
|
+
wrapper = f"{svg_block}{wrapper}"
|
|
212
|
+
|
|
213
|
+
del ncols # silence unused — reserved for future column sizing
|
|
214
|
+
return f"{style}{wrapper}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ----------------------------------------------------------------------
|
|
218
|
+
# Style block (the <style>...</style> path; still emitted alongside the
|
|
219
|
+
# inline attributes because it covers things inline can't, like
|
|
220
|
+
# :hover, child selectors, sticky-header positioning).
|
|
221
|
+
# ----------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def _build_style(theme: Theme, scope_id: str, *, sticky: bool = False) -> str:
|
|
224
|
+
"""Assemble the scoped stylesheet for one rendered table.
|
|
225
|
+
|
|
226
|
+
We deliberately do *not* set an explicit foreground colour — every
|
|
227
|
+
cell inherits the surrounding context (Jupyter's notebook stylesheet,
|
|
228
|
+
or the wrapping HTML document) and borders use ``currentColor`` so
|
|
229
|
+
they always have the same contrast as the text. This avoids the
|
|
230
|
+
common failure mode where a hard-coded dark-mode colour leaks onto a
|
|
231
|
+
light page (and vice-versa).
|
|
232
|
+
"""
|
|
233
|
+
blocks: list[str] = []
|
|
234
|
+
for selector, decls in theme.css.items():
|
|
235
|
+
scoped = _scope(selector, scope_id)
|
|
236
|
+
decl_str = "".join(f"{k}:{v};" for k, v in decls.items())
|
|
237
|
+
blocks.append(f"{scoped}{{{decl_str}}}")
|
|
238
|
+
if sticky:
|
|
239
|
+
blocks.append(
|
|
240
|
+
f"table.{scope_id} thead th"
|
|
241
|
+
"{position:sticky;top:0;background:var(--jp-cell-editor-background,#fff);"
|
|
242
|
+
"z-index:1;}"
|
|
243
|
+
)
|
|
244
|
+
return f"<style>{''.join(blocks)}</style>"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _scope(selector: str, scope_id: str) -> str:
|
|
248
|
+
"""Prefix every comma-separated selector with the unique scope class."""
|
|
249
|
+
parts = [p.strip() for p in selector.split(",")]
|
|
250
|
+
return ", ".join(f".{scope_id} {p}" if p != "table" else f"table.{scope_id}" for p in parts)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ----------------------------------------------------------------------
|
|
254
|
+
# Sections
|
|
255
|
+
# ----------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def _merge_style(*parts: str) -> str:
|
|
258
|
+
"""Merge style fragments, dropping empties and ensuring a single ``;`` join."""
|
|
259
|
+
cleaned = [p.rstrip(";") for p in parts if p]
|
|
260
|
+
return ";".join(cleaned)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _render_caption(table: SofraTable, inlines: _ThemeInlines) -> str:
|
|
264
|
+
if not table.caption:
|
|
265
|
+
return ""
|
|
266
|
+
style = inlines.caption
|
|
267
|
+
style_attr = f' style="{style}"' if style else ""
|
|
268
|
+
return f"<caption{style_attr}>{html.escape(table.caption)}</caption>"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _render_thead(table: SofraTable, inlines: _ThemeInlines) -> str:
|
|
272
|
+
rows: list[str] = []
|
|
273
|
+
if table.spanning_headers:
|
|
274
|
+
rows.append(_render_spanning_row(table.spanning_headers, _ncols(table), inlines))
|
|
275
|
+
for hr in table.headers:
|
|
276
|
+
rows.append(_render_header_row(hr, inlines))
|
|
277
|
+
if not rows:
|
|
278
|
+
return ""
|
|
279
|
+
return "<thead>" + "".join(rows) + "</thead>"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _render_spanning_row(
|
|
283
|
+
spans: tuple[SpanningHeader, ...], ncols: int, inlines: _ThemeInlines,
|
|
284
|
+
) -> str:
|
|
285
|
+
# Build a placeholder list — each column is either covered by a span
|
|
286
|
+
# (with the rendered <th colspan>) or rendered as an empty <th>.
|
|
287
|
+
covered = [False] * ncols
|
|
288
|
+
cells_by_index: dict[int, str] = {}
|
|
289
|
+
span_style = (
|
|
290
|
+
f' style="{inlines.spanning}"' if inlines.spanning else ""
|
|
291
|
+
)
|
|
292
|
+
for span in spans:
|
|
293
|
+
size = span.end - span.start + 1
|
|
294
|
+
cells_by_index[span.start] = (
|
|
295
|
+
f'<th class="pysofra-spanning" colspan="{size}"{span_style}>'
|
|
296
|
+
f"{html.escape(span.label)}</th>"
|
|
297
|
+
)
|
|
298
|
+
for i in range(span.start, span.end + 1):
|
|
299
|
+
covered[i] = True
|
|
300
|
+
|
|
301
|
+
out: list[str] = []
|
|
302
|
+
i = 0
|
|
303
|
+
while i < ncols:
|
|
304
|
+
if i in cells_by_index:
|
|
305
|
+
out.append(cells_by_index[i])
|
|
306
|
+
# skip to end of span
|
|
307
|
+
span = next(s for s in spans if s.start == i)
|
|
308
|
+
i = span.end + 1
|
|
309
|
+
else:
|
|
310
|
+
if not covered[i]:
|
|
311
|
+
out.append("<th></th>")
|
|
312
|
+
i += 1
|
|
313
|
+
return "<tr>" + "".join(out) + "</tr>"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _render_header_row(hr: HeaderRow, inlines: _ThemeInlines) -> str:
|
|
317
|
+
return "<tr>" + "".join(_render_header_cell(c, inlines) for c in hr.cells) + "</tr>"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _render_header_cell(c: HeaderCell, inlines: _ThemeInlines) -> str:
|
|
321
|
+
# Per-cell overrides (alignment, bold) come AFTER the theme styles
|
|
322
|
+
# so they win the cascade — last declaration wins for equal
|
|
323
|
+
# specificity within a single inline attribute.
|
|
324
|
+
per_cell_parts: list[str] = []
|
|
325
|
+
if c.align:
|
|
326
|
+
per_cell_parts.append(f"text-align:{c.align}")
|
|
327
|
+
if c.bold:
|
|
328
|
+
per_cell_parts.append("font-weight:600")
|
|
329
|
+
per_cell = ";".join(per_cell_parts)
|
|
330
|
+
style = _merge_style(inlines.th, per_cell)
|
|
331
|
+
style_attr = f' style="{style}"' if style else ""
|
|
332
|
+
# Newlines in header text indicate stacked label (e.g. "Group A\nN=10").
|
|
333
|
+
parts = c.text.split("\n")
|
|
334
|
+
body = "<br>".join(html.escape(p) for p in parts) if len(parts) > 1 else html.escape(c.text)
|
|
335
|
+
return f'<th{style_attr}>{body}</th>'
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _render_tbody(table: SofraTable, inlines: _ThemeInlines) -> str:
|
|
339
|
+
if not table.rows:
|
|
340
|
+
return "<tbody></tbody>"
|
|
341
|
+
last_idx = len(table.rows) - 1
|
|
342
|
+
return (
|
|
343
|
+
"<tbody>"
|
|
344
|
+
+ "".join(
|
|
345
|
+
_render_row(r, inlines, is_last=(i == last_idx))
|
|
346
|
+
for i, r in enumerate(table.rows)
|
|
347
|
+
)
|
|
348
|
+
+ "</tbody>"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _render_row(r: Row, inlines: _ThemeInlines, *, is_last: bool) -> str:
|
|
353
|
+
cls = " class=\"group-header\"" if r.is_group_header else ""
|
|
354
|
+
style = ""
|
|
355
|
+
if r.metadata:
|
|
356
|
+
highlight = r.metadata.get("highlight")
|
|
357
|
+
if highlight:
|
|
358
|
+
style = f' style="background:{html.escape(str(highlight))};"'
|
|
359
|
+
return (
|
|
360
|
+
f"<tr{cls}{style}>"
|
|
361
|
+
+ "".join(_render_cell(c, inlines, is_last_row=is_last) for c in r.cells)
|
|
362
|
+
+ "</tr>"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _render_cell(c: Cell, inlines: _ThemeInlines, *, is_last_row: bool) -> str:
|
|
367
|
+
classes: list[str] = []
|
|
368
|
+
per_cell_parts: list[str] = []
|
|
369
|
+
if c.kind in ("numeric", "p_value", "ci"):
|
|
370
|
+
classes.append("pysofra-num")
|
|
371
|
+
if c.align:
|
|
372
|
+
per_cell_parts.append(f"text-align:{c.align}")
|
|
373
|
+
if c.bold:
|
|
374
|
+
classes.append("pysofra-bold")
|
|
375
|
+
if c.italic:
|
|
376
|
+
per_cell_parts.append("font-style:italic")
|
|
377
|
+
if c.indent > 0:
|
|
378
|
+
per_cell_parts.append(f"padding-left:{0.75 + 1.0 * c.indent:.2f}em")
|
|
379
|
+
# Cell-level renderer-specific overrides (style['html'] is appended as-is).
|
|
380
|
+
if c.style and isinstance(c.style.get("html"), str):
|
|
381
|
+
extra = c.style["html"].strip().rstrip(";")
|
|
382
|
+
if extra:
|
|
383
|
+
per_cell_parts.append(extra)
|
|
384
|
+
|
|
385
|
+
# Theme inline styles first (lowest priority within the attribute);
|
|
386
|
+
# per-cell overrides last so they win for equal-specificity props.
|
|
387
|
+
theme_part = inlines.td
|
|
388
|
+
if is_last_row and inlines.last_row_td:
|
|
389
|
+
# last-row border-bottom must override the general td rule
|
|
390
|
+
theme_part = _merge_style(theme_part, inlines.last_row_td)
|
|
391
|
+
style = _merge_style(theme_part, ";".join(per_cell_parts))
|
|
392
|
+
|
|
393
|
+
class_attr = f' class="{" ".join(classes)}"' if classes else ""
|
|
394
|
+
style_attr = f' style="{style}"' if style else ""
|
|
395
|
+
body = _render_parts(c) if c.parts else html.escape(c.text)
|
|
396
|
+
return f"<td{class_attr}{style_attr}>{body}</td>"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _render_parts(c: Cell) -> str:
|
|
400
|
+
"""Render a rich cell (``c.parts``) as HTML runs."""
|
|
401
|
+
if not c.parts:
|
|
402
|
+
return html.escape(c.text)
|
|
403
|
+
out: list[str] = []
|
|
404
|
+
for p in c.parts:
|
|
405
|
+
s = html.escape(p.text)
|
|
406
|
+
if p.code:
|
|
407
|
+
s = f"<code>{s}</code>"
|
|
408
|
+
if p.superscript:
|
|
409
|
+
s = f"<sup>{s}</sup>"
|
|
410
|
+
if p.subscript:
|
|
411
|
+
s = f"<sub>{s}</sub>"
|
|
412
|
+
if p.italic:
|
|
413
|
+
s = f"<em>{s}</em>"
|
|
414
|
+
if p.bold:
|
|
415
|
+
s = f"<strong>{s}</strong>"
|
|
416
|
+
if p.color:
|
|
417
|
+
s = f'<span style="color:{html.escape(p.color)};">{s}</span>'
|
|
418
|
+
if p.link:
|
|
419
|
+
s = f'<a href="{html.escape(p.link)}">{s}</a>'
|
|
420
|
+
out.append(s)
|
|
421
|
+
return "".join(out)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _render_tfoot(table: SofraTable, inlines: _ThemeInlines) -> str:
|
|
425
|
+
if not table.footnotes:
|
|
426
|
+
return ""
|
|
427
|
+
ncols = _ncols(table)
|
|
428
|
+
lines = "<br>".join(html.escape(f) for f in table.footnotes)
|
|
429
|
+
style_attr = f' style="{inlines.tfoot_td}"' if inlines.tfoot_td else ""
|
|
430
|
+
return (
|
|
431
|
+
"<tfoot><tr>"
|
|
432
|
+
f'<td colspan="{ncols}"{style_attr}>{lines}</td>'
|
|
433
|
+
"</tr></tfoot>"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _ncols(table: SofraTable) -> int:
|
|
438
|
+
if table.headers:
|
|
439
|
+
return len(table.headers[0].cells)
|
|
440
|
+
if table.rows:
|
|
441
|
+
return len(table.rows[0].cells)
|
|
442
|
+
return 1
|
pysofra/render/image.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Render a SofraTable to a PNG image.
|
|
2
|
+
|
|
3
|
+
Uses matplotlib's ``ax.table`` to draw the structural representation of
|
|
4
|
+
the table on a Figure, then saves to PNG. Captions are rendered above
|
|
5
|
+
the table; footnotes below. Spanning headers are drawn as a separate
|
|
6
|
+
row at the top.
|
|
7
|
+
|
|
8
|
+
This renderer is intentionally simple — for publication-quality output
|
|
9
|
+
the LaTeX / DOCX / HTML backends produce better results. PNG export
|
|
10
|
+
exists for screenshot-style previews and for embedding inside a notebook
|
|
11
|
+
when an SVG isn't desired.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..core.schema import HeaderRow, Row, SpanningHeader
|
|
20
|
+
from ..core.table import SofraTable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def write_image(
|
|
24
|
+
table: SofraTable,
|
|
25
|
+
path: Path,
|
|
26
|
+
*,
|
|
27
|
+
scale: float = 2.0,
|
|
28
|
+
dpi: int = 300,
|
|
29
|
+
) -> Path:
|
|
30
|
+
try:
|
|
31
|
+
from ..plot._backend import use_headless_backend
|
|
32
|
+
use_headless_backend()
|
|
33
|
+
import matplotlib.pyplot as plt
|
|
34
|
+
except ImportError as e: # pragma: no cover
|
|
35
|
+
raise ImportError(
|
|
36
|
+
"to_image() requires matplotlib. Install with `pip install matplotlib`."
|
|
37
|
+
) from e
|
|
38
|
+
|
|
39
|
+
ncols = _ncols(table)
|
|
40
|
+
n_header_rows = (1 if table.spanning_headers else 0) + len(table.headers)
|
|
41
|
+
n_body_rows = len(table.rows)
|
|
42
|
+
total_rows = n_header_rows + n_body_rows
|
|
43
|
+
# Approximate sizing: 1.2 in per column, 0.35 in per row, plus header/footer.
|
|
44
|
+
width = max(4.0, 1.2 * ncols * scale)
|
|
45
|
+
height = max(2.0, 0.35 * total_rows * scale + 1.2)
|
|
46
|
+
|
|
47
|
+
fig, ax = plt.subplots(figsize=(width, height), dpi=dpi)
|
|
48
|
+
ax.axis("off")
|
|
49
|
+
|
|
50
|
+
# Caption above.
|
|
51
|
+
if table.caption:
|
|
52
|
+
ax.set_title(table.caption, loc="left", fontweight="bold", fontsize=11)
|
|
53
|
+
|
|
54
|
+
grid: list[list[str]] = []
|
|
55
|
+
spans: list[tuple[int, int, int, str]] = [] # (row, start, end, label)
|
|
56
|
+
row_idx = 0
|
|
57
|
+
if table.spanning_headers:
|
|
58
|
+
# Reserve a row for spans.
|
|
59
|
+
grid.append([""] * ncols)
|
|
60
|
+
for s in table.spanning_headers:
|
|
61
|
+
spans.append((row_idx, s.start, s.end, s.label))
|
|
62
|
+
row_idx += 1
|
|
63
|
+
|
|
64
|
+
for hr in table.headers:
|
|
65
|
+
grid.append([c.text.replace("\n", " · ") for c in hr.cells])
|
|
66
|
+
row_idx += 1
|
|
67
|
+
for r in table.rows:
|
|
68
|
+
prefix = " " * (r.cells[0].indent if r.cells else 0)
|
|
69
|
+
grid.append(
|
|
70
|
+
[prefix + c.text if j == 0 else c.text
|
|
71
|
+
for j, c in enumerate(r.cells)]
|
|
72
|
+
)
|
|
73
|
+
row_idx += 1
|
|
74
|
+
|
|
75
|
+
if not grid:
|
|
76
|
+
grid = [[""] * ncols]
|
|
77
|
+
tbl = ax.table(
|
|
78
|
+
cellText=grid,
|
|
79
|
+
cellLoc="left",
|
|
80
|
+
loc="center",
|
|
81
|
+
)
|
|
82
|
+
tbl.auto_set_font_size(False)
|
|
83
|
+
tbl.set_fontsize(9)
|
|
84
|
+
tbl.scale(1.0, 1.4)
|
|
85
|
+
|
|
86
|
+
# Bold header rows.
|
|
87
|
+
for j in range(ncols):
|
|
88
|
+
cell = tbl[(0 if table.spanning_headers else 0, j)]
|
|
89
|
+
cell.set_text_props(weight="bold")
|
|
90
|
+
for i_h in range(n_header_rows):
|
|
91
|
+
tbl[(i_h, j)].set_text_props(weight="bold")
|
|
92
|
+
tbl[(i_h, j)].set_facecolor("#f2f2f2")
|
|
93
|
+
|
|
94
|
+
# Apply span labels as merged-looking cells (matplotlib doesn't really
|
|
95
|
+
# merge; we just write the label into the leftmost cell of the span
|
|
96
|
+
# and blank out the others, but the visual cue is enough for previews).
|
|
97
|
+
for row_i, start, end, label in spans:
|
|
98
|
+
tbl[(row_i, start)].get_text().set_text(label)
|
|
99
|
+
tbl[(row_i, start)].set_text_props(weight="bold",
|
|
100
|
+
horizontalalignment="center")
|
|
101
|
+
for k in range(start + 1, end + 1):
|
|
102
|
+
tbl[(row_i, k)].get_text().set_text("")
|
|
103
|
+
tbl[(row_i, k)].set_facecolor("#f2f2f2")
|
|
104
|
+
|
|
105
|
+
# Footnotes below.
|
|
106
|
+
if table.footnotes:
|
|
107
|
+
fn_text = "\n".join(table.footnotes)
|
|
108
|
+
ax.text(
|
|
109
|
+
0, -0.05, fn_text,
|
|
110
|
+
transform=ax.transAxes,
|
|
111
|
+
fontsize=8, style="italic",
|
|
112
|
+
verticalalignment="top",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
path = Path(path)
|
|
116
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
fig.savefig(path, bbox_inches="tight", dpi=dpi)
|
|
118
|
+
plt.close(fig)
|
|
119
|
+
return path
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _ncols(table: SofraTable) -> int:
|
|
123
|
+
if table.headers:
|
|
124
|
+
return len(table.headers[0].cells)
|
|
125
|
+
if table.rows:
|
|
126
|
+
return len(table.rows[0].cells)
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_ = (Any, HeaderRow, Row, SpanningHeader) # silence unused-import linter
|