sdvplot 0.1.0__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 (49) hide show
  1. sdvplot/__init__.py +68 -0
  2. sdvplot/_cache.py +484 -0
  3. sdvplot/_colors.py +206 -0
  4. sdvplot/_contrast.py +60 -0
  5. sdvplot/_court.py +110 -0
  6. sdvplot/_deprecate.py +39 -0
  7. sdvplot/_dispatch.py +342 -0
  8. sdvplot/_errors.py +93 -0
  9. sdvplot/_headshots.py +131 -0
  10. sdvplot/_images.py +292 -0
  11. sdvplot/_index.py +178 -0
  12. sdvplot/_lazy.py +15 -0
  13. sdvplot/_manifest.py +73 -0
  14. sdvplot/_marks.py +296 -0
  15. sdvplot/_normalize.py +82 -0
  16. sdvplot/_placement.py +168 -0
  17. sdvplot/_resolve.py +363 -0
  18. sdvplot/_surface.py +220 -0
  19. sdvplot/_tables.py +87 -0
  20. sdvplot/_tiers.py +135 -0
  21. sdvplot/_types.py +38 -0
  22. sdvplot/_versions.py +30 -0
  23. sdvplot/_web.py +61 -0
  24. sdvplot/altair.py +652 -0
  25. sdvplot/bokeh.py +323 -0
  26. sdvplot/data/INDEX_VERSION +1 -0
  27. sdvplot/data/aliases.parquet +0 -0
  28. sdvplot/data/teams.parquet +0 -0
  29. sdvplot/folium.py +316 -0
  30. sdvplot/great_tables/__init__.py +267 -0
  31. sdvplot/great_tables/_cells.py +1814 -0
  32. sdvplot/great_tables/_export.py +844 -0
  33. sdvplot/great_tables/_layout.py +1802 -0
  34. sdvplot/great_tables/_marks.py +798 -0
  35. sdvplot/great_tables/_themes.py +2134 -0
  36. sdvplot/holoviews.py +303 -0
  37. sdvplot/matplotlib.py +934 -0
  38. sdvplot/plotly.py +595 -0
  39. sdvplot/plotnine.py +1104 -0
  40. sdvplot/plottable.py +141 -0
  41. sdvplot/py.typed +0 -0
  42. sdvplot/pygal.py +416 -0
  43. sdvplot/reactable.py +488 -0
  44. sdvplot/testing.py +579 -0
  45. sdvplot/typing.py +21 -0
  46. sdvplot-0.1.0.dist-info/METADATA +188 -0
  47. sdvplot-0.1.0.dist-info/RECORD +49 -0
  48. sdvplot-0.1.0.dist-info/WHEEL +4 -0
  49. sdvplot-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,844 @@
1
+ """Image export and composition for great_tables: sdvplotR's gt_save_crop, gt_save_batch, gt_social_crop, gt_grid and
2
+ gt_stack_tables.
3
+
4
+ A table renders through great_tables' own ``GT.gtsave`` (headless Chrome over nokap, which great_tables 1.0 installs)
5
+ and composed HTML through ``nokap.from_html``, the counterparts of the ``gtExtras::gtsave_extra`` and
6
+ ``webshot2::webshot`` calls sdvplotR makes. Trimming, padding, placing and resizing are Pillow ports of its magick
7
+ calls, checked against ImageMagick 6.9 in the tests.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import html
13
+ import math
14
+ import numbers
15
+ import re
16
+ import sys
17
+ import tempfile
18
+ from collections import Counter
19
+ from collections.abc import Callable, Iterable, Mapping, Sequence
20
+ from os import PathLike
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import htmltools
25
+ import narwhals as nw
26
+ from great_tables import GT
27
+ from PIL import Image, ImageChops, ImageColor
28
+
29
+ from sdvplot._errors import warn
30
+ from sdvplot.great_tables._marks import _check_gt
31
+
32
+ _GRAVITY = ("center", "north", "south", "east", "west", "northwest", "northeast", "southwest", "southeast")
33
+ _JPEG_QUALITY = 92 # magick's default; Pillow's own (75) blurs table text
34
+
35
+ # ---------------------------------------------------------------------------------------------------------------------
36
+ # Rendering (the only steps that need Chrome; tests replace these two functions)
37
+
38
+
39
+ def _open_rgb(path: Path) -> Image.Image:
40
+ with Image.open(path) as im:
41
+ return im.convert("RGB")
42
+
43
+
44
+ def _render_gt(gt: GT, zoom: float, expand: int) -> Image.Image:
45
+ """Screenshot one table with great_tables' gtsave, as sdvplotR's gtsave_extra(zoom, expand)."""
46
+ with tempfile.TemporaryDirectory() as tmp:
47
+ path = Path(tmp) / "table.png"
48
+ gt.gtsave(path, zoom=zoom, expand=expand)
49
+ return _open_rgb(path)
50
+
51
+
52
+ def _render_html(page: str, zoom: float) -> Image.Image:
53
+ """Screenshot the element ``#sdvplot-page`` of an HTML page, as sdvplotR's webshot2::webshot(zoom=zoom)."""
54
+ import nokap # a great_tables dependency; imported here so the module loads without starting anything
55
+
56
+ with tempfile.TemporaryDirectory() as tmp:
57
+ path = Path(tmp) / "page.png"
58
+ nokap.from_html(page, path, selector="#sdvplot-page", zoom=zoom)
59
+ return _open_rgb(path)
60
+
61
+
62
+ # ---------------------------------------------------------------------------------------------------------------------
63
+ # Pillow ports of the magick calls
64
+
65
+
66
+ def _trim(img: Image.Image) -> Image.Image:
67
+ """magick::image_trim(): crop uniform edges. As ImageMagick, the left and top edges match the top-left pixel, the
68
+ right edge the top-right pixel and the bottom edge the bottom-left pixel (fuzz 0). One color everywhere is
69
+ returned unchanged (magick raises there)."""
70
+ w, h = img.size
71
+
72
+ def box(xy: tuple[int, int]) -> tuple[int, int, int, int] | None:
73
+ return ImageChops.difference(img, Image.new(img.mode, img.size, img.getpixel(xy))).getbbox()
74
+
75
+ tl, tr, bl = box((0, 0)), box((w - 1, 0)), box((0, h - 1))
76
+ if tl is None or tr is None or bl is None or tr[2] <= tl[0] or bl[3] <= tl[1]:
77
+ return img
78
+ return img.crop((tl[0], tl[1], tr[2], bl[3]))
79
+
80
+
81
+ def _extent(img: Image.Image, width: int, height: int, bg: str, gravity: str = "center") -> Image.Image:
82
+ """magick::image_extent(): ``img`` on a ``width`` x ``height`` canvas of ``bg``, placed by ``gravity``."""
83
+ dx, dy = width - img.width, height - img.height
84
+ x = 0 if gravity.endswith("west") else dx if gravity.endswith("east") else dx // 2
85
+ y = 0 if gravity.startswith("north") else dy if gravity.startswith("south") else dy // 2
86
+ canvas = Image.new("RGB", (width, height), bg)
87
+ canvas.paste(img, (x, y))
88
+ return canvas
89
+
90
+
91
+ def _pad(img: Image.Image, bg: str, whitespace: int) -> Image.Image:
92
+ """magick::image_border(bg, "{whitespace}x{whitespace}")."""
93
+ return _extent(img, img.width + 2 * whitespace, img.height + 2 * whitespace, bg)
94
+
95
+
96
+ def _fit_width(img: Image.Image, width: int) -> Image.Image:
97
+ """magick::image_resize("{width}x"): scale to ``width``, the height following (rounded half up, as ImageMagick)."""
98
+ height = max(1, math.floor(img.height * width / img.width + 0.5))
99
+ return img.resize((width, height), Image.Resampling.LANCZOS)
100
+
101
+
102
+ def _canvas(width: int, height: int, ratio: float) -> tuple[int, int]:
103
+ """gt_social_crop's canvas: grow the short side until width / height is ``ratio`` (R's round: half to even)."""
104
+ if width / height > ratio:
105
+ return width, round(width / ratio)
106
+ return round(height * ratio), height
107
+
108
+
109
+ def _ratio(aspect_ratio: str | float) -> float:
110
+ """``"16:9"``, ``"4x5"`` or a number -> a positive finite ratio."""
111
+ ratio = math.nan
112
+ try:
113
+ if isinstance(aspect_ratio, str) and re.search("[:x]", aspect_ratio):
114
+ a, b = re.split("[:x]", aspect_ratio)
115
+ ratio = float(a) / float(b)
116
+ elif not isinstance(aspect_ratio, bool):
117
+ ratio = float(aspect_ratio)
118
+ except (TypeError, ValueError, ZeroDivisionError):
119
+ pass
120
+ if not (ratio > 0 and math.isfinite(ratio)):
121
+ raise ValueError(
122
+ f"aspect_ratio could not be read as a ratio: got {aspect_ratio!r}; use '1:1', '16:9', '4x5' or a number "
123
+ "like 1.91"
124
+ )
125
+ return ratio
126
+
127
+
128
+ # ---------------------------------------------------------------------------------------------------------------------
129
+ # Argument checks: all run before anything renders, so a typo never costs a browser start
130
+
131
+
132
+ def _finite(value: Any) -> bool:
133
+ return isinstance(value, numbers.Real) and not isinstance(value, bool) and math.isfinite(value)
134
+
135
+
136
+ def _pixels(name: str, value: Any, *, positive: bool = False) -> int:
137
+ if not _finite(value) or value < 0 or (positive and value == 0):
138
+ raise ValueError(
139
+ f"{name} must be a {'positive' if positive else 'non-negative'} number of pixels, got {value!r}"
140
+ )
141
+ return int(value)
142
+
143
+
144
+ def _check_zoom(zoom: Any) -> None:
145
+ if not (_finite(zoom) and zoom > 0):
146
+ raise ValueError(f"zoom must be a positive number, got {zoom!r}")
147
+
148
+
149
+ def _check_file(file: str | PathLike[str] | None) -> None:
150
+ if file is None:
151
+ return
152
+ fmt = Image.registered_extensions().get(Path(file).suffix.lower())
153
+ if fmt is None or fmt not in Image.SAVE:
154
+ raise ValueError(f"file must end in an image extension such as .png, .jpg or .jpeg, got {str(file)!r}")
155
+
156
+
157
+ def _check_common(bg: str, whitespace: Any, width: Any, file: str | PathLike[str] | None) -> tuple[int, int | None]:
158
+ ImageColor.getrgb(bg) # ValueError: unknown color specifier
159
+ _check_file(file)
160
+ return _pixels("whitespace", whitespace), None if width is None else _pixels("width", width, positive=True)
161
+
162
+
163
+ def _check_gravity(gravity: str) -> str:
164
+ g = str(gravity).lower()
165
+ if g not in _GRAVITY:
166
+ raise ValueError(f"gravity must be one of {', '.join(_GRAVITY)}, got {gravity!r}")
167
+ return g
168
+
169
+
170
+ def _finish(img: Image.Image, file: str | PathLike[str] | None, width: int | None) -> Any:
171
+ if width is not None:
172
+ img = _fit_width(img, width)
173
+ if file is None:
174
+ return img
175
+ img.save(file, quality=_JPEG_QUALITY)
176
+ return file
177
+
178
+
179
+ # ---------------------------------------------------------------------------------------------------------------------
180
+ # Single tables
181
+
182
+
183
+ def gt_save_crop(
184
+ data: GT,
185
+ file: str | PathLike[str] | None = None,
186
+ *,
187
+ bg: str = "white",
188
+ whitespace: int = 50,
189
+ zoom: float = 2,
190
+ expand: int = 5,
191
+ width: int | None = None,
192
+ ) -> Any:
193
+ """Save a table to an image, trimmed to its content with an even border.
194
+
195
+ Renders the table in headless Chrome (great_tables' ``GT.gtsave``), trims the page around it and pads a
196
+ ``whitespace`` border of ``bg`` back on.
197
+
198
+ Args:
199
+ data: The great_tables ``GT`` to save.
200
+ file: A path ending in an image extension Pillow writes (``.png``, ``.jpg``, ``.jpeg``, ...). ``None`` returns
201
+ the image instead of writing it.
202
+ bg: The border color: a CSS color name or hex code.
203
+ whitespace: The border, in pixels, left around the trimmed table.
204
+ zoom: The rendering zoom, a positive number; 2 gives a sharp (retina) image.
205
+ expand: Pixels of page captured around the table before trimming.
206
+ width: A final width in pixels, the height following, so a series of tables shares one width. ``None`` keeps
207
+ the rendered width.
208
+
209
+ Returns:
210
+ str | os.PathLike | PIL.Image.Image: ``file`` after writing it, or the image when ``file`` is ``None``.
211
+
212
+ Raises:
213
+ TypeError: If ``data`` is not a ``GT``.
214
+ ValueError: If ``bg``, ``whitespace``, ``width``, ``zoom`` or the extension of ``file`` is invalid (checked
215
+ before rendering).
216
+ nokap.ChromeNotFoundError: If no Chrome or Chromium is installed (set ``CHROME_PATH`` to point at one).
217
+
218
+ Example:
219
+ ::
220
+
221
+ from great_tables import GT
222
+ from sdvplot.great_tables import gt_save_crop
223
+ import polars as pl
224
+
225
+ df = pl.DataFrame(
226
+ {
227
+ "team": ["KC", "BUF", "BAL"],
228
+ "espn_id": ["3139477", "3918298", "3916387"],
229
+ "wins": [12, 10, 9],
230
+ }
231
+ )
232
+
233
+ gt_save_crop(GT(df), "table.png", bg="#FBFAF7", width=900)
234
+
235
+ See Also:
236
+ gt_social_crop: the same, padded onto a fixed-ratio canvas.
237
+ Ported from sdvplotR ``gt_save_crop()``: https://sdvplotR.sportsdataverse.org/reference/gt_save_crop.html
238
+ """
239
+ _check_gt(data, "data")
240
+ _check_zoom(zoom)
241
+ pad, final = _check_common(bg, whitespace, width, file)
242
+ return _finish(_pad(_trim(_render_gt(data, zoom, expand)), bg, pad), file, final)
243
+
244
+
245
+ def gt_social_crop(
246
+ data: GT,
247
+ file: str | PathLike[str] | None = None,
248
+ *,
249
+ aspect_ratio: str | float = "1:1",
250
+ bg: str = "white",
251
+ whitespace: int = 60,
252
+ gravity: str = "center",
253
+ zoom: float = 2,
254
+ expand: int = 5,
255
+ width: int | None = None,
256
+ ) -> Any:
257
+ """Save a table centered on a canvas of a fixed aspect ratio, for social posts.
258
+
259
+ The trimmed table is never cropped: the canvas' short side grows until the ratio is met.
260
+
261
+ Args:
262
+ data: The great_tables ``GT`` to save.
263
+ file: A path ending in an image extension (``.png``, ``.jpg``, ...). ``None`` returns the image.
264
+ aspect_ratio: The canvas ratio, width to height: ``"1:1"``, ``"16:9"``, ``"4x5"`` or a number such as 1.91.
265
+ bg: The canvas color.
266
+ whitespace: Pixels left around the table before the canvas grows to the ratio.
267
+ gravity: Where the table sits on the canvas, as in magick: ``"center"``, ``"north"``, ``"south"``,
268
+ ``"east"``, ``"west"``, ``"northwest"``, ``"northeast"``, ``"southwest"`` or ``"southeast"``.
269
+ zoom: The rendering zoom.
270
+ expand: Pixels of page captured around the table before trimming.
271
+ width: A final width in pixels for the finished canvas, the ratio held.
272
+
273
+ Returns:
274
+ str | os.PathLike | PIL.Image.Image: ``file`` after writing it, or the image when ``file`` is ``None``.
275
+
276
+ Raises:
277
+ TypeError: If ``data`` is not a ``GT``.
278
+ ValueError: If ``aspect_ratio`` is not a positive ratio, ``gravity`` is unknown, or ``bg``, ``whitespace``,
279
+ ``width``, ``zoom`` or the extension of ``file`` is invalid (all checked before rendering).
280
+ nokap.ChromeNotFoundError: If no Chrome or Chromium is installed.
281
+
282
+ Example:
283
+ ::
284
+
285
+ from great_tables import GT
286
+ from sdvplot.great_tables import gt_social_crop
287
+ import polars as pl
288
+
289
+ df = pl.DataFrame(
290
+ {
291
+ "team": ["KC", "BUF", "BAL"],
292
+ "espn_id": ["3139477", "3918298", "3916387"],
293
+ "wins": [12, 10, 9],
294
+ }
295
+ )
296
+
297
+ gt_social_crop(GT(df), "post.png", aspect_ratio="4:5", bg="#0C0D10")
298
+
299
+ See Also:
300
+ gt_save_crop: a plain trimmed save.
301
+ Ported from sdvplotR ``gt_social_crop()``: https://sdvplotR.sportsdataverse.org/reference/gt_social_crop.html
302
+ """
303
+ _check_gt(data, "data")
304
+ ratio = _ratio(aspect_ratio)
305
+ place = _check_gravity(gravity)
306
+ _check_zoom(zoom)
307
+ pad, final = _check_common(bg, whitespace, width, file)
308
+ img = _pad(_trim(_render_gt(data, zoom, expand)), bg, pad)
309
+ img = _extent(img, *_canvas(img.width, img.height, ratio), bg, place)
310
+ return _finish(img, file, final)
311
+
312
+
313
+ # ---------------------------------------------------------------------------------------------------------------------
314
+ # Batches
315
+
316
+
317
+ def _slug(value: Any) -> str:
318
+ return re.sub(r"[^A-Za-z0-9._-]+", "-", str(value)).strip("-").lower()
319
+
320
+
321
+ def gt_save_batch(
322
+ data: Any,
323
+ group: str,
324
+ fn: Callable[[Any, Any], GT],
325
+ file: str,
326
+ *,
327
+ dir: str | PathLike[str],
328
+ match_width: bool = True,
329
+ bg: str = "white",
330
+ whitespace: int = 50,
331
+ zoom: float = 2,
332
+ quiet: bool = False,
333
+ ) -> list[str]:
334
+ """Save a matched set of table images, one per group.
335
+
336
+ Splits ``data`` by a column, builds a table per group with ``fn`` and writes one image per group, padded to a
337
+ common width so a posted series is not ragged. A group whose table fails to build or render is skipped and named
338
+ in one warning at the end; the rest are still written.
339
+
340
+ Args:
341
+ data: A pandas or polars DataFrame (any narwhals-supported eager frame).
342
+ group: The column to split on. Its missing values are skipped.
343
+ fn: Builds one table, called as ``fn(df, value)`` with the group's rows (the same frame type as ``data``) and
344
+ its value; it must return a ``GT``.
345
+ file: A file name containing ``{group}``, replaced by the group value with anything awkward turned into a
346
+ dash and lower-cased, so ``"North / East"`` writes ``"net-north-east.png"`` for ``"net-{group}.png"``.
347
+ dir: The directory to write into, created when missing. It has no default, so a batch never lands in the
348
+ working directory unasked; pass ``"."`` for that.
349
+ match_width: Pad every image to the widest one's width.
350
+ bg: The padding color.
351
+ whitespace: The border, in pixels, around each table.
352
+ zoom: The rendering zoom.
353
+ quiet: Do not print the per-group progress lines (to stderr).
354
+
355
+ Returns:
356
+ list[str]: The files written, in group order.
357
+
358
+ Raises:
359
+ TypeError: If ``data`` is not a data frame or ``fn`` is not callable.
360
+ ValueError: If ``group`` is not a column, has no non-missing values, two values would write the same file,
361
+ ``file`` lacks ``{group}`` or an image extension, or ``bg``, ``whitespace`` or ``zoom`` is invalid (all
362
+ before rendering).
363
+ RuntimeError: If no group built.
364
+ nokap.ChromeNotFoundError: If no browser can start (raised at the first group, not collected per group).
365
+
366
+ Example:
367
+ ::
368
+
369
+ from great_tables import GT
370
+ from sdvplot.great_tables import gt_save_batch
371
+ import polars as pl
372
+
373
+ cars = pl.DataFrame({"cyl": [4, 4, 6], "mpg": [22.8, 24.4, 21.0]})
374
+
375
+ def build(df, value):
376
+ return GT(df).tab_header(title=f"{value} cylinders")
377
+
378
+ gt_save_batch(cars, "cyl", build, "cars-{group}.png", dir="out")
379
+
380
+ See Also:
381
+ gt_grid: the same split composed into one image.
382
+ Ported from sdvplotR ``gt_save_batch()``: https://sdvplotR.sportsdataverse.org/reference/gt_save_batch.html
383
+ """
384
+ from nokap import ChromeNotFoundError, ChromeStartError
385
+
386
+ frame = nw.from_native(data, eager_only=True)
387
+ if not callable(fn):
388
+ raise TypeError("fn must be a function returning a GT")
389
+ if "{group}" not in file:
390
+ raise ValueError(f"file must contain {{group}}, as in 'net-{{group}}.png', got {file!r}")
391
+ if not isinstance(group, str) or group not in frame.columns:
392
+ raise ValueError(f"group must name one column of data, got {group!r}")
393
+ pad, _ = _check_common(bg, whitespace, None, file.replace("{group}", "x"))
394
+ _check_zoom(zoom)
395
+ keys = frame[group].drop_nulls().unique(maintain_order=True).to_list()
396
+ if not keys:
397
+ raise ValueError(f"group {group!r} has no non-missing values")
398
+ names = [file.replace("{group}", _slug(k)) for k in keys]
399
+ shared = sorted(n for n, c in Counter(names).items() if c > 1)
400
+ if shared:
401
+ raise ValueError(f"group values write to the same file name: {', '.join(shared)}; rename the values first")
402
+
403
+ built: list[tuple[str, Image.Image]] = []
404
+ failed: list[str] = []
405
+ for key, name in zip(keys, names, strict=True):
406
+ if not quiet:
407
+ print(f"Building {key!r}", file=sys.stderr)
408
+ try:
409
+ tbl = fn(frame.filter(nw.col(group) == key).to_native(), key)
410
+ _check_gt(tbl, "fn's return value")
411
+ built.append((name, _trim(_render_gt(tbl, zoom, 5))))
412
+ except (ChromeNotFoundError, ChromeStartError):
413
+ raise # no browser: every group would fail the same way
414
+ except Exception as e:
415
+ failed.append(f"{key}: {e}")
416
+ if not built:
417
+ raise RuntimeError("No group built successfully:\n" + "\n".join(failed))
418
+
419
+ widest = max(img.width for _, img in built) if match_width else None
420
+ Path(dir).mkdir(parents=True, exist_ok=True)
421
+ paths = []
422
+ for name, img in built:
423
+ if widest is not None:
424
+ img = _extent(img, widest, img.height, bg)
425
+ path = str(Path(dir) / name)
426
+ _pad(img, bg, pad).save(path, quality=_JPEG_QUALITY)
427
+ paths.append(path)
428
+ if failed:
429
+ warn(f"{len(failed)} group(s) failed and were skipped:\n" + "\n".join(failed))
430
+ if not quiet:
431
+ print(f"Wrote {len(paths)} file(s) to {dir}", file=sys.stderr)
432
+ return paths
433
+
434
+
435
+ # ---------------------------------------------------------------------------------------------------------------------
436
+ # Composition (sdvplotR's utils-style.R *_style lists and the shared header/footer)
437
+
438
+ _STYLE_KEYS = (
439
+ "font",
440
+ "size",
441
+ "color",
442
+ "weight",
443
+ "italic",
444
+ "spacing",
445
+ "transform",
446
+ "align",
447
+ "line_height",
448
+ "margin_top",
449
+ "margin_bottom",
450
+ "padding_top",
451
+ "padding_bottom",
452
+ )
453
+ _CSS_PROPS = (
454
+ ("size", "font-size"),
455
+ ("color", "color"),
456
+ ("weight", "font-weight"),
457
+ ("spacing", "letter-spacing"),
458
+ ("transform", "text-transform"),
459
+ ("align", "text-align"),
460
+ ("line_height", "line-height"),
461
+ ("margin_top", "margin-top"),
462
+ ("margin_bottom", "margin-bottom"),
463
+ ("padding_top", "padding-top"),
464
+ ("padding_bottom", "padding-bottom"),
465
+ )
466
+ _LENGTHS = {"size", "spacing", "margin_top", "margin_bottom", "padding_top", "padding_bottom"}
467
+ _FONT_FALLBACK = "system-ui, -apple-system, sans-serif"
468
+ _STYLE_DEFAULTS: dict[str, dict[str, Any]] = {
469
+ "title": {"size": "28px", "weight": 700, "color": "#111111", "align": "center", "margin_bottom": 4},
470
+ "subtitle": {"size": "16px", "weight": 400, "color": "#666666", "align": "center", "margin_bottom": 12},
471
+ "caption": {"size": "12px", "weight": 400, "color": "#8A8A8A", "align": "center", "margin_top": 10},
472
+ "source_note": {"size": "12px", "weight": 400, "color": "#8A8A8A", "align": "right", "margin_top": 6},
473
+ "label": {"size": "12px", "weight": 600, "color": "#555555", "align": "left", "margin_bottom": 6},
474
+ }
475
+
476
+
477
+ def _style(arg: str, user: Mapping[str, Any] | None, default: Mapping[str, Any] | None = None) -> dict[str, Any]:
478
+ """A style dict (sdvplotR's ``*_style`` lists): ``default`` with the caller's keys (argument ``arg``) on top."""
479
+ user = dict(user or {})
480
+ unknown = sorted(set(user) - set(_STYLE_KEYS))
481
+ if unknown:
482
+ raise ValueError(f"{arg} has unknown key(s) {unknown}; recognized: {', '.join(_STYLE_KEYS)}")
483
+ return {**(default or {}), **user}
484
+
485
+
486
+ def _css_len(value: Any) -> str:
487
+ """A number (numpy numbers included) reads as pixels; anything else is a CSS length already."""
488
+ return f"{value}px" if isinstance(value, numbers.Real) and not isinstance(value, bool) else str(value)
489
+
490
+
491
+ def _style_css(style: Mapping[str, Any], font_fallback: str | None = None) -> str:
492
+ """A style dict -> inline CSS, in sdvplotR's .style_css() order.
493
+
494
+ With ``font_fallback`` the font family is always set (composed HTML, which has no table font to inherit); without
495
+ it a style with no font sets none, so the text inherits the theme's (the wave C2 headers and legends).
496
+ """
497
+ font = style.get("font")
498
+ out = []
499
+ if font is not None:
500
+ out.append(f"font-family:'{font}', {font_fallback or 'sans-serif'};")
501
+ elif font_fallback is not None:
502
+ out.append(f"font-family:{font_fallback};")
503
+ for key, prop in _CSS_PROPS:
504
+ value = style.get(key)
505
+ if value is not None:
506
+ out.append(f"{prop}:{_css_len(value) if key in _LENGTHS else value};")
507
+ if key == "weight" and style.get("italic") is True:
508
+ out.append("font-style:italic;")
509
+ return "".join(out)
510
+
511
+
512
+ def _text(value: Any) -> htmltools.HTML:
513
+ """A title, caption or label: md() and html() text render as markup; anything else is escaped, as great_tables
514
+ does (sdvplotR inserts plain strings as HTML)."""
515
+ return htmltools.HTML(value.to_html() if hasattr(value, "to_html") else html.escape(str(value)))
516
+
517
+
518
+ def _fonts(*styles: Mapping[str, Any]) -> list[str]:
519
+ """The distinct Google font names across style dicts, in order."""
520
+ return list(dict.fromkeys(s["font"] for s in styles if s.get("font") is not None))
521
+
522
+
523
+ def _font_link(styles: Sequence[Mapping[str, Any]]) -> htmltools.Tag | None:
524
+ """composed HTML never runs through great_tables' google_font(), so a named font is fetched here."""
525
+ fonts = _fonts(*styles)
526
+ if not fonts:
527
+ return None
528
+ families = "&".join(f"family={f.replace(' ', '+')}:wght@100..900" for f in fonts)
529
+ return htmltools.tags.link(rel="stylesheet", href=f"https://fonts.googleapis.com/css2?{families}&display=swap")
530
+
531
+
532
+ def _tables(tables: Any) -> list[GT]:
533
+ if isinstance(tables, GT):
534
+ raise TypeError("tables must be a list of GT objects; wrap a single table as [gt]")
535
+ items = [] if tables is None else list(tables.values()) if isinstance(tables, Mapping) else list(tables)
536
+ if not items:
537
+ raise ValueError("tables must be a non-empty list of GT objects")
538
+ bad = [i for i, t in enumerate(items) if not isinstance(t, GT)]
539
+ if bad:
540
+ raise TypeError(f"tables must contain only GT objects; item(s) {bad} are not")
541
+ return items
542
+
543
+
544
+ def _table_html(gt: GT) -> htmltools.Tag:
545
+ return htmltools.div(htmltools.HTML(gt.as_raw_html()))
546
+
547
+
548
+ def _compose(
549
+ body: htmltools.Tag,
550
+ *,
551
+ title: Any,
552
+ subtitle: Any,
553
+ caption: Any,
554
+ source_note: Any,
555
+ caption_rule: bool,
556
+ title_style: Mapping[str, Any] | None,
557
+ subtitle_style: Mapping[str, Any] | None,
558
+ caption_style: Mapping[str, Any] | None,
559
+ source_note_style: Mapping[str, Any] | None,
560
+ more_styles: Sequence[Mapping[str, Any]] = (),
561
+ ) -> htmltools.Tag:
562
+ """The shared heading and footer around a grid or stack, in a shrink-to-fit wrapper (sdvplotR's layout)."""
563
+ s_title = _style("title_style", title_style, _STYLE_DEFAULTS["title"])
564
+ s_subtitle = _style("subtitle_style", subtitle_style, _STYLE_DEFAULTS["subtitle"])
565
+ s_caption = _style("caption_style", caption_style, _STYLE_DEFAULTS["caption"])
566
+ s_source = _style("source_note_style", source_note_style, _STYLE_DEFAULTS["source_note"])
567
+ # with no subtitle the title carries the gap the subtitle would have held
568
+ if subtitle is None and "margin_bottom" not in (title_style or {}):
569
+ s_title["margin_bottom"] = _STYLE_DEFAULTS["subtitle"]["margin_bottom"]
570
+ if caption_rule and "padding_bottom" not in (caption_style or {}):
571
+ s_caption["padding_bottom"] = 6
572
+ link = _font_link([s_title, s_subtitle, s_caption, s_source, *more_styles])
573
+
574
+ has_header = title is not None or subtitle is not None
575
+ has_footer = caption is not None or source_note is not None
576
+ if not (has_header or has_footer):
577
+ return htmltools.div(link, body) if link is not None else body
578
+ rule = f"border-bottom:1px solid {s_caption['color']};" if caption_rule else ""
579
+ header = footer = None
580
+ if has_header:
581
+ header = htmltools.div(
582
+ htmltools.div(_text(title), style=_style_css(s_title, _FONT_FALLBACK)) if title is not None else None,
583
+ htmltools.div(_text(subtitle), style=_style_css(s_subtitle, _FONT_FALLBACK))
584
+ if subtitle is not None
585
+ else None,
586
+ )
587
+ if has_footer:
588
+ footer = htmltools.div(
589
+ htmltools.div(_text(caption), style=_style_css(s_caption, _FONT_FALLBACK) + rule)
590
+ if caption is not None
591
+ else None,
592
+ htmltools.div(_text(source_note), style=_style_css(s_source, _FONT_FALLBACK))
593
+ if source_note is not None
594
+ else None,
595
+ )
596
+ # inner wrapper shrinks to the tables, outer one recenters it; "safe" centering plus overflow-x keeps a sheet
597
+ # wider than a phone scrollable
598
+ return htmltools.div(
599
+ link,
600
+ htmltools.div(header, body, footer, style="display: inline-block;"),
601
+ style="display: flex; justify-content: center; justify-content: safe center; overflow-x: auto;",
602
+ )
603
+
604
+
605
+ def _save_composed(
606
+ composed: htmltools.Tag, file: str | PathLike[str], bg: str, whitespace: int, zoom: float
607
+ ) -> str | PathLike[str]:
608
+ # the wrapper, not the page, is captured: it is bg on every edge, so trimming is even on all four sides
609
+ page = htmltools.div(
610
+ composed, id="sdvplot-page", style=f"display: inline-block; padding: 8px; background-color: {bg};"
611
+ )
612
+ doc = f'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>{page}</body></html>'
613
+ _pad(_trim(_render_html(doc, zoom)), bg, whitespace).save(file, quality=_JPEG_QUALITY)
614
+ return file
615
+
616
+
617
+ def gt_grid(
618
+ tables: Sequence[GT] | Mapping[Any, GT] | None = None,
619
+ *,
620
+ ncol: int = 2,
621
+ labels: Any = None,
622
+ label_style: Mapping[str, Any] | None = None,
623
+ title: Any = None,
624
+ subtitle: Any = None,
625
+ caption: Any = None,
626
+ source_note: Any = None,
627
+ caption_rule: bool = False,
628
+ title_style: Mapping[str, Any] | None = None,
629
+ subtitle_style: Mapping[str, Any] | None = None,
630
+ caption_style: Mapping[str, Any] | None = None,
631
+ source_note_style: Mapping[str, Any] | None = None,
632
+ gap: float = 24,
633
+ align: str = "top",
634
+ file: str | PathLike[str] | None = None,
635
+ bg: str = "white",
636
+ whitespace: int = 50,
637
+ zoom: float = 2,
638
+ ) -> Any:
639
+ """Arrange several tables in a grid of rows and columns, as small multiples.
640
+
641
+ The grid is HTML, not a ``GT``: each table keeps its own columns and header, so this is a last step after every
642
+ table is themed. Give ``file`` to write it straight to an image.
643
+
644
+ Args:
645
+ tables: A list of ``GT`` objects (a dict's values are used in order, so ``gt_theme_preview()``'s dict works).
646
+ ncol: The number of tables across.
647
+ labels: A caption above each table, recycled across ``tables``: a string, ``md()``/``html()`` text, or a
648
+ non-empty list of them. Plain strings are escaped; use ``html()`` for markup.
649
+ label_style: Style for the labels (see ``title_style``).
650
+ title: A heading above the whole grid: a string, ``md()`` or ``html()``. Plain strings are escaped (as in
651
+ great_tables); use ``html()`` for markup. ``subtitle``, ``caption`` and ``source_note`` take the same.
652
+ subtitle: A line below ``title``.
653
+ caption: A note below the grid.
654
+ source_note: A second line below ``caption``, right-aligned by default.
655
+ caption_rule: Draw a hairline between ``caption`` and ``source_note``.
656
+ title_style: A dict of any of ``font`` (a Google font name), ``size``, ``color``, ``weight``, ``italic``,
657
+ ``spacing``, ``transform``, ``align``, ``line_height``, ``margin_top``, ``margin_bottom``,
658
+ ``padding_top``, ``padding_bottom``. Lengths take a number (pixels) or a CSS string; keys left out keep
659
+ their defaults.
660
+ subtitle_style: As ``title_style``, for the subtitle.
661
+ caption_style: As ``title_style``, for the caption.
662
+ source_note_style: As ``title_style``, for the source note.
663
+ gap: The space between tables, a non-negative number of pixels.
664
+ align: How tables of differing height line up in a row: ``"top"``, ``"center"`` or ``"bottom"``.
665
+ file: A path to write an image to; ``None`` returns the HTML.
666
+ bg: The background color when saving.
667
+ whitespace: Padding, in pixels, around the grid when saving.
668
+ zoom: The rendering zoom when saving.
669
+
670
+ Returns:
671
+ htmltools.Tag | str | os.PathLike: The grid as HTML (it displays in a notebook), or ``file`` after writing it.
672
+
673
+ Raises:
674
+ TypeError: If ``tables`` holds anything but ``GT`` objects.
675
+ TypeError: If ``labels`` is neither text nor a list of it.
676
+ ValueError: If ``tables`` or ``labels`` is empty, ``ncol`` is below 1, ``gap`` is not a non-negative number,
677
+ ``zoom`` is not a positive number, ``align`` or a style key is unknown, or (when saving) ``bg``,
678
+ ``whitespace`` or the extension of ``file`` is invalid.
679
+ nokap.ChromeNotFoundError: If saving and no Chrome or Chromium is installed.
680
+
681
+ Example:
682
+ ::
683
+
684
+ import polars as pl
685
+ from great_tables import GT
686
+ from sdvplot.great_tables import gt_grid
687
+
688
+ east = GT(pl.DataFrame({"team": ["BUF", "MIA"], "wins": [11, 9]}))
689
+ west = GT(pl.DataFrame({"team": ["KC", "LV"], "wins": [12, 8]}))
690
+ north = GT(pl.DataFrame({"team": ["BAL", "CIN"], "wins": [10, 9]}))
691
+ south = GT(pl.DataFrame({"team": ["HOU", "IND"], "wins": [10, 8]}))
692
+
693
+ gt_grid([east, west, north, south], ncol=2, title="Division leaders", caption="Data: ESPN")
694
+ gt_grid([east, west], file="divisions.png", bg="#FBFAF7")
695
+
696
+ See Also:
697
+ gt_stack_tables: a vertical stack.
698
+ Ported from sdvplotR ``gt_grid()``: https://sdvplotR.sportsdataverse.org/reference/gt_grid.html
699
+ """
700
+ items = _tables(tables)
701
+ if isinstance(ncol, bool) or not isinstance(ncol, numbers.Integral) or ncol < 1:
702
+ raise ValueError(f"ncol must be an integer of at least 1, got {ncol!r}")
703
+ places = {"top": "start", "center": "center", "bottom": "end"}
704
+ if align not in places:
705
+ raise ValueError(f"align must be one of {', '.join(places)}, got {align!r}")
706
+ _pixels("gap", gap)
707
+ _check_zoom(zoom)
708
+ s_label = _style("label_style", label_style, _STYLE_DEFAULTS["label"])
709
+ pad = 0 if file is None else _check_common(bg, whitespace, None, file)[0]
710
+ if labels is not None:
711
+ if isinstance(labels, str) or hasattr(labels, "to_html"):
712
+ labs = [labels]
713
+ elif isinstance(labels, Iterable):
714
+ labs = list(labels)
715
+ else:
716
+ raise TypeError(
717
+ f"labels must be a string, md() or html() text, or a list of them, not {type(labels).__name__}"
718
+ )
719
+ if not labs:
720
+ raise ValueError("labels must be non-empty; pass None for no labels")
721
+
722
+ if labels is None:
723
+ cells = [_table_html(t) for t in items]
724
+ else:
725
+ cells = [
726
+ htmltools.div(
727
+ htmltools.div(_text(labs[i % len(labs)]), style=_style_css(s_label, _FONT_FALLBACK)), _table_html(t)
728
+ )
729
+ for i, t in enumerate(items)
730
+ ]
731
+ grid = htmltools.div(
732
+ *cells,
733
+ style=f"display: grid; grid-template-columns: repeat({ncol}, max-content); gap: {gap}px; "
734
+ f"align-items: {places[align]}; justify-content: center;",
735
+ )
736
+ composed = _compose(
737
+ grid,
738
+ title=title,
739
+ subtitle=subtitle,
740
+ caption=caption,
741
+ source_note=source_note,
742
+ caption_rule=caption_rule,
743
+ title_style=title_style,
744
+ subtitle_style=subtitle_style,
745
+ caption_style=caption_style,
746
+ source_note_style=source_note_style,
747
+ more_styles=[s_label] if labels is not None else [],
748
+ )
749
+ return composed if file is None else _save_composed(composed, file, bg, pad, zoom)
750
+
751
+
752
+ def gt_stack_tables(
753
+ tables: Sequence[GT] | Mapping[Any, GT] | None = None,
754
+ *,
755
+ gap: float = 16,
756
+ align: str = "center",
757
+ title: Any = None,
758
+ subtitle: Any = None,
759
+ caption: Any = None,
760
+ source_note: Any = None,
761
+ caption_rule: bool = False,
762
+ title_style: Mapping[str, Any] | None = None,
763
+ subtitle_style: Mapping[str, Any] | None = None,
764
+ caption_style: Mapping[str, Any] | None = None,
765
+ source_note_style: Mapping[str, Any] | None = None,
766
+ file: str | PathLike[str] | None = None,
767
+ bg: str = "white",
768
+ whitespace: int = 50,
769
+ zoom: float = 2,
770
+ ) -> Any:
771
+ """Stack several tables vertically in one block, with an optional shared heading and footer.
772
+
773
+ The stack is HTML, not a ``GT``: each table keeps its own columns, widths and header. Give ``file`` to write it
774
+ straight to an image.
775
+
776
+ Args:
777
+ tables: A list of ``GT`` objects (a dict's values are used in order).
778
+ gap: The space between tables, a non-negative number of pixels.
779
+ align: How tables of differing width line up: ``"center"``, ``"left"`` or ``"right"``.
780
+ title: A heading above the stack: a string, ``md()`` or ``html()``. Plain strings are escaped (as in
781
+ great_tables); use ``html()`` for markup. ``subtitle``, ``caption`` and ``source_note`` take the same.
782
+ subtitle: A line below ``title``.
783
+ caption: A note below the stack.
784
+ source_note: A second line below ``caption``, right-aligned by default.
785
+ caption_rule: Draw a hairline between ``caption`` and ``source_note``.
786
+ title_style: A style dict, with the keys of ``gt_grid``'s ``title_style``.
787
+ subtitle_style: As ``title_style``, for the subtitle.
788
+ caption_style: As ``title_style``, for the caption.
789
+ source_note_style: As ``title_style``, for the source note.
790
+ file: A path to write an image to; ``None`` returns the HTML.
791
+ bg: The background color when saving.
792
+ whitespace: Padding, in pixels, around the stack when saving.
793
+ zoom: The rendering zoom when saving.
794
+
795
+ Returns:
796
+ htmltools.Tag | str | os.PathLike: The stack as HTML (it displays in a notebook), or ``file`` after writing it.
797
+
798
+ Raises:
799
+ TypeError: If ``tables`` holds anything but ``GT`` objects.
800
+ ValueError: If ``tables`` is empty, ``gap`` is not a non-negative number, ``zoom`` is not a positive number,
801
+ ``align`` or a style key is unknown, or (when saving) ``bg``, ``whitespace`` or the extension of ``file``
802
+ is invalid.
803
+ nokap.ChromeNotFoundError: If saving and no Chrome or Chromium is installed.
804
+
805
+ Example:
806
+ ::
807
+
808
+ import polars as pl
809
+ from great_tables import GT
810
+ from sdvplot.great_tables import gt_stack_tables
811
+
812
+ offense = GT(pl.DataFrame({"team": ["KC", "BUF"], "epa": [0.2, 0.15]}))
813
+ defense = GT(pl.DataFrame({"team": ["BAL", "SF"], "epa": [-0.1, -0.08]}))
814
+
815
+ gt_stack_tables([offense, defense], title="Two tables", title_style={"font": "Oswald", "size": 30})
816
+
817
+ See Also:
818
+ gt_grid: tables side by side.
819
+ Ported from sdvplotR ``gt_stack_tables()``: https://sdvplotR.sportsdataverse.org/reference/gt_stack_tables.html
820
+ """
821
+ items = _tables(tables)
822
+ places = {"left": "flex-start", "center": "center", "right": "flex-end"}
823
+ if align not in places:
824
+ raise ValueError(f"align must be one of {', '.join(places)}, got {align!r}")
825
+ _pixels("gap", gap)
826
+ _check_zoom(zoom)
827
+ pad = 0 if file is None else _check_common(bg, whitespace, None, file)[0]
828
+ stack = htmltools.div(
829
+ *[_table_html(t) for t in items],
830
+ style=f"display: flex; flex-direction: column; gap: {gap}px; align-items: {places[align]};",
831
+ )
832
+ composed = _compose(
833
+ stack,
834
+ title=title,
835
+ subtitle=subtitle,
836
+ caption=caption,
837
+ source_note=source_note,
838
+ caption_rule=caption_rule,
839
+ title_style=title_style,
840
+ subtitle_style=subtitle_style,
841
+ caption_style=caption_style,
842
+ source_note_style=source_note_style,
843
+ )
844
+ return composed if file is None else _save_composed(composed, file, bg, pad, zoom)