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/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
@@ -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