reportsmith 0.2.0__tar.gz → 0.3.0__tar.gz

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 (29) hide show
  1. {reportsmith-0.2.0/reportsmith.egg-info → reportsmith-0.3.0}/PKG-INFO +8 -3
  2. {reportsmith-0.2.0 → reportsmith-0.3.0}/README.md +6 -1
  3. {reportsmith-0.2.0 → reportsmith-0.3.0}/pyproject.toml +1 -1
  4. {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/__init__.py +3 -1
  5. reportsmith-0.3.0/reportsmith/data.py +214 -0
  6. {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/elements.py +132 -1
  7. {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/renderers/html.py +48 -2
  8. {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/renderers/latex.py +8 -0
  9. {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/renderers/markdown.py +23 -0
  10. {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/renderers/pdf.py +81 -0
  11. reportsmith-0.3.0/reportsmith/toc.py +111 -0
  12. {reportsmith-0.2.0 → reportsmith-0.3.0/reportsmith.egg-info}/PKG-INFO +8 -3
  13. {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith.egg-info/SOURCES.txt +5 -1
  14. reportsmith-0.3.0/tests/test_data.py +293 -0
  15. reportsmith-0.3.0/tests/test_toc.py +264 -0
  16. {reportsmith-0.2.0 → reportsmith-0.3.0}/LICENSE +0 -0
  17. {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/inline.py +0 -0
  18. {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/renderers/__init__.py +0 -0
  19. {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/report.py +0 -0
  20. {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith.egg-info/dependency_links.txt +0 -0
  21. {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith.egg-info/requires.txt +0 -0
  22. {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith.egg-info/top_level.txt +0 -0
  23. {reportsmith-0.2.0 → reportsmith-0.3.0}/setup.cfg +0 -0
  24. {reportsmith-0.2.0 → reportsmith-0.3.0}/tests/test_elements.py +0 -0
  25. {reportsmith-0.2.0 → reportsmith-0.3.0}/tests/test_inline.py +0 -0
  26. {reportsmith-0.2.0 → reportsmith-0.3.0}/tests/test_latex.py +0 -0
  27. {reportsmith-0.2.0 → reportsmith-0.3.0}/tests/test_pdf.py +0 -0
  28. {reportsmith-0.2.0 → reportsmith-0.3.0}/tests/test_renderers.py +0 -0
  29. {reportsmith-0.2.0 → reportsmith-0.3.0}/tests/test_reportsmith.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reportsmith
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A small, dependency-free library for building reports and exporting them to Markdown, HTML, LaTeX and PDF
5
5
  Author: Michal Stojke
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/mstojke/reportsmith
8
- Project-URL: Documentation, https://github.com/mstojke/reportsmith/blob/main/docs/user-guide.md
8
+ Project-URL: Documentation, https://reportsmith.readthedocs.io/en/latest/index.html
9
9
  Project-URL: Changelog, https://github.com/mstojke/reportsmith/blob/main/CHANGELOG.md
10
10
  Project-URL: Issues, https://github.com/mstojke/reportsmith/issues
11
11
  Keywords: report,markdown,html,latex,pdf,export,document
@@ -35,6 +35,8 @@ A small, dependency-free Python library for building reports programmatically an
35
35
  ## Features
36
36
 
37
37
  - Fluent builder API with chainable methods and nested sections (`with report.section(...)`)
38
+ - Table of contents in every format: linked lists in Markdown/HTML, `\tableofcontents` in LaTeX, clickable entries with page numbers in PDF
39
+ - Tables straight from data: lists of dicts, CSV files, or pandas DataFrames, with number formatting, missing-value placeholders, and automatic right-alignment of numeric columns
38
40
  - Elements: headings, paragraphs, tables (with alignment and captions), bullet/numbered lists (nestable), code blocks, block quotes, images, horizontal rules, and format-specific raw passthrough
39
41
  - Inline formatting helpers: `bold()`, `italic()`, `code()`, `link()` — render correctly in both formats
40
42
  - Correct escaping everywhere: HTML entities, Markdown table pipes, code fences that widen when the source contains ` ``` `
@@ -57,6 +59,7 @@ Or just copy the `reportsmith/` package into your project — it has no dependen
57
59
  from reportsmith import Report, bold, link
58
60
 
59
61
  report = Report("Q3 Sales Report", author="Jane Doe", subtitle="Quarterly summary")
62
+ report.toc() # table of contents
60
63
 
61
64
  with report.section("Overview") as s:
62
65
  s.paragraph(["Revenue was ", bold("up 12%"), " quarter over quarter."])
@@ -116,9 +119,11 @@ Builder methods (all chainable, also available on sections):
116
119
  | `code(source, language="")` | Fenced/`<pre>` code block |
117
120
  | `bullet_list(items)` / `numbered_list(items)` | List; items may be text or a nested `ListElement` |
118
121
  | `table(headers, rows, caption="", align=None)` | Table; `align` entries are `"left"`, `"center"`, `"right"` |
122
+ | `table_from_records(records)` / `table_from_csv(source)` / `table_from_dataframe(df)` | Table built from data; options `columns`, `headers`, `formats`, `missing`, `align`, `caption` |
119
123
  | `image(src, alt="", title="")` | Image |
120
124
  | `horizontal_rule()` | Rule |
121
125
  | `raw(content, format="both")` | Verbatim content for `"markdown"`, `"html"`, `"latex"`, or `"both"` (Markdown + HTML) |
126
+ | `toc(title="Contents", depth=3)` | Table of contents for the whole report; `depth` 1–5 sets how many heading levels are listed |
122
127
  | `add(element)` | Any pre-built element |
123
128
 
124
129
  Anywhere text is accepted you may pass a plain string or a list mixing strings with `bold()`, `italic()`, `code()`, and `link(text, href)` spans.
@@ -133,7 +138,7 @@ Anywhere text is accepted you may pass a plain string or a list mixing strings w
133
138
 
134
139
  ## Running tests
135
140
 
136
- The test suite (151 tests) lives in `tests/`:
141
+ The test suite (256 tests) lives in `tests/`:
137
142
 
138
143
  ```bash
139
144
  python -m pytest
@@ -5,6 +5,8 @@ A small, dependency-free Python library for building reports programmatically an
5
5
  ## Features
6
6
 
7
7
  - Fluent builder API with chainable methods and nested sections (`with report.section(...)`)
8
+ - Table of contents in every format: linked lists in Markdown/HTML, `\tableofcontents` in LaTeX, clickable entries with page numbers in PDF
9
+ - Tables straight from data: lists of dicts, CSV files, or pandas DataFrames, with number formatting, missing-value placeholders, and automatic right-alignment of numeric columns
8
10
  - Elements: headings, paragraphs, tables (with alignment and captions), bullet/numbered lists (nestable), code blocks, block quotes, images, horizontal rules, and format-specific raw passthrough
9
11
  - Inline formatting helpers: `bold()`, `italic()`, `code()`, `link()` — render correctly in both formats
10
12
  - Correct escaping everywhere: HTML entities, Markdown table pipes, code fences that widen when the source contains ` ``` `
@@ -27,6 +29,7 @@ Or just copy the `reportsmith/` package into your project — it has no dependen
27
29
  from reportsmith import Report, bold, link
28
30
 
29
31
  report = Report("Q3 Sales Report", author="Jane Doe", subtitle="Quarterly summary")
32
+ report.toc() # table of contents
30
33
 
31
34
  with report.section("Overview") as s:
32
35
  s.paragraph(["Revenue was ", bold("up 12%"), " quarter over quarter."])
@@ -86,9 +89,11 @@ Builder methods (all chainable, also available on sections):
86
89
  | `code(source, language="")` | Fenced/`<pre>` code block |
87
90
  | `bullet_list(items)` / `numbered_list(items)` | List; items may be text or a nested `ListElement` |
88
91
  | `table(headers, rows, caption="", align=None)` | Table; `align` entries are `"left"`, `"center"`, `"right"` |
92
+ | `table_from_records(records)` / `table_from_csv(source)` / `table_from_dataframe(df)` | Table built from data; options `columns`, `headers`, `formats`, `missing`, `align`, `caption` |
89
93
  | `image(src, alt="", title="")` | Image |
90
94
  | `horizontal_rule()` | Rule |
91
95
  | `raw(content, format="both")` | Verbatim content for `"markdown"`, `"html"`, `"latex"`, or `"both"` (Markdown + HTML) |
96
+ | `toc(title="Contents", depth=3)` | Table of contents for the whole report; `depth` 1–5 sets how many heading levels are listed |
92
97
  | `add(element)` | Any pre-built element |
93
98
 
94
99
  Anywhere text is accepted you may pass a plain string or a list mixing strings with `bold()`, `italic()`, `code()`, and `link(text, href)` spans.
@@ -103,7 +108,7 @@ Anywhere text is accepted you may pass a plain string or a list mixing strings w
103
108
 
104
109
  ## Running tests
105
110
 
106
- The test suite (151 tests) lives in `tests/`:
111
+ The test suite (256 tests) lives in `tests/`:
107
112
 
108
113
  ```bash
109
114
  python -m pytest
@@ -21,7 +21,7 @@ classifiers = [
21
21
 
22
22
  [project.urls]
23
23
  Homepage = "https://github.com/mstojke/reportsmith"
24
- Documentation = "https://github.com/mstojke/reportsmith/blob/main/docs/user-guide.md"
24
+ Documentation = "https://reportsmith.readthedocs.io/en/latest/index.html"
25
25
  Changelog = "https://github.com/mstojke/reportsmith/blob/main/CHANGELOG.md"
26
26
  Issues = "https://github.com/mstojke/reportsmith/issues"
27
27
 
@@ -29,6 +29,7 @@ from .elements import (
29
29
  Raw,
30
30
  Section,
31
31
  Table,
32
+ TableOfContents,
32
33
  )
33
34
  from .report import Report
34
35
  from .renderers.markdown import MarkdownRenderer
@@ -36,7 +37,7 @@ from .renderers.html import HtmlRenderer
36
37
  from .renderers.pdf import PdfRenderer
37
38
  from .renderers.latex import LatexRenderer
38
39
 
39
- __version__ = "0.2.0"
40
+ __version__ = "0.3.0"
40
41
 
41
42
  __all__ = [
42
43
  "Report",
@@ -44,6 +45,7 @@ __all__ = [
44
45
  "Heading",
45
46
  "Paragraph",
46
47
  "Table",
48
+ "TableOfContents",
47
49
  "ListElement",
48
50
  "CodeBlock",
49
51
  "Image",
@@ -0,0 +1,214 @@
1
+ """Build tables from structured data: records, CSV files and DataFrames.
2
+
3
+ pandas is never imported: DataFrames are read through their public
4
+ ``columns`` / ``itertuples()`` interface, so reportsmith stays
5
+ dependency-free and works with any DataFrame-like object.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import io
12
+ import numbers
13
+ import re
14
+ from collections.abc import Mapping
15
+ from pathlib import Path
16
+ from typing import IO, TYPE_CHECKING, Any, Callable, Iterable, Optional, Sequence, Union
17
+
18
+ from .inline import Inline, TextLike
19
+
20
+ if TYPE_CHECKING:
21
+ from .elements import Table
22
+
23
+ #: A format string such as ``"{:,.2f}"`` or a callable returning cell text.
24
+ CellFormat = Union[str, Callable[[Any], TextLike]]
25
+ #: One format for every numeric cell, or a per-column mapping.
26
+ Formats = Union[CellFormat, Mapping[Any, CellFormat], None]
27
+ #: Display headers, either one per selected column or a key -> label mapping.
28
+ Headers = Union[Sequence[TextLike], Mapping[Any, TextLike], None]
29
+
30
+ _INT_RE = re.compile(r"[+-]?(?:0|[1-9]\d*)")
31
+ _FLOAT_RE = re.compile(r"[+-]?(?:\d+\.\d*|\.\d+)(?:[eE][+-]?\d+)?|[+-]?\d+[eE][+-]?\d+")
32
+
33
+
34
+ def is_missing(value: Any) -> bool:
35
+ """None, NaN (float, numpy or Decimal) and pandas ``NA`` / ``NaT``."""
36
+ if value is None or type(value).__name__ in ("NAType", "NaTType"):
37
+ return True
38
+ return isinstance(value, numbers.Number) and value != value
39
+
40
+
41
+ def is_numeric(value: Any) -> bool:
42
+ return isinstance(value, numbers.Number) and not isinstance(value, bool)
43
+
44
+
45
+ def parse_number(text: str) -> Union[str, int, float]:
46
+ """Convert numeric-looking CSV text to int/float; leave everything else.
47
+
48
+ Integers with leading zeros (IDs, ZIP codes) and thousands separators are
49
+ kept as text so no information is lost.
50
+ """
51
+ stripped = text.strip()
52
+ if _INT_RE.fullmatch(stripped):
53
+ return int(stripped)
54
+ if _FLOAT_RE.fullmatch(stripped):
55
+ return float(stripped)
56
+ return text
57
+
58
+
59
+ def build_table(
60
+ keys: Sequence[Any],
61
+ rows: Sequence[Sequence[Any]],
62
+ *,
63
+ columns: Optional[Sequence[Any]] = None,
64
+ headers: Headers = None,
65
+ formats: Formats = None,
66
+ align: Optional[Sequence[str]] = None,
67
+ missing: str = "",
68
+ caption: str = "",
69
+ ) -> "Table":
70
+ """Shared core: select columns, format values and build a :class:`Table`."""
71
+ from .elements import Table
72
+
73
+ if columns is not None:
74
+ unknown = [c for c in columns if c not in keys]
75
+ if unknown:
76
+ raise ValueError(f"Unknown column(s): {', '.join(map(repr, unknown))}")
77
+ indices = [list(keys).index(c) for c in columns]
78
+ keys = list(columns)
79
+ rows = [[row[i] for i in indices] for row in rows]
80
+ keys = list(keys)
81
+
82
+ if headers is None:
83
+ labels: list = [str(k) for k in keys]
84
+ elif isinstance(headers, Mapping):
85
+ _check_keys(headers, keys, "headers")
86
+ labels = [headers.get(k, str(k)) for k in keys]
87
+ else:
88
+ labels = list(headers)
89
+ if len(labels) != len(keys):
90
+ raise ValueError(f"headers has {len(labels)} entries, expected {len(keys)}")
91
+
92
+ if formats is None or isinstance(formats, str) or callable(formats):
93
+ default_format, per_column = formats, {}
94
+ else:
95
+ _check_keys(formats, keys, "formats")
96
+ default_format, per_column = None, dict(formats)
97
+
98
+ columns_values = list(zip(*rows)) if rows else [() for _ in keys]
99
+ if align is None:
100
+ align = ["right" if _numeric_column(values) else "left" for values in columns_values]
101
+
102
+ cells = [
103
+ [
104
+ _format(value, per_column.get(key), default_format, missing)
105
+ for key, value in zip(keys, row)
106
+ ]
107
+ for row in rows
108
+ ]
109
+ return Table(labels, cells, caption=caption, align=align)
110
+
111
+
112
+ def from_records(
113
+ records: Iterable[Mapping[Any, Any]],
114
+ columns: Optional[Sequence[Any]] = None,
115
+ **options: Any,
116
+ ) -> "Table":
117
+ records = list(records)
118
+ for i, record in enumerate(records):
119
+ if not isinstance(record, Mapping):
120
+ raise TypeError(f"Record {i} is a {type(record).__name__}, expected a mapping")
121
+ keys: list = []
122
+ for record in records: # union of keys in first-seen order
123
+ keys.extend(k for k in record if k not in keys)
124
+ if columns is not None and records:
125
+ # Unlike keys absent from some records (filled with ``missing``), a
126
+ # column absent from every record is almost certainly a typo.
127
+ unknown = [c for c in columns if c not in keys]
128
+ if unknown:
129
+ raise ValueError(f"Unknown column(s): {', '.join(map(repr, unknown))}")
130
+ elif columns is not None:
131
+ keys = list(columns)
132
+ rows = [[record.get(k) for k in keys] for record in records]
133
+ return build_table(keys, rows, columns=columns, **options)
134
+
135
+
136
+ def from_csv(
137
+ source: Union[str, Path, IO[str]],
138
+ columns: Optional[Sequence[str]] = None,
139
+ *,
140
+ delimiter: str = ",",
141
+ encoding: str = "utf-8",
142
+ parse_numbers: bool = True,
143
+ **options: Any,
144
+ ) -> "Table":
145
+ if hasattr(source, "read"):
146
+ text = source.read() # type: ignore[union-attr]
147
+ else:
148
+ text = Path(source).read_text(encoding=encoding)
149
+ # utf-8-sig behaviour for any encoding: drop a leading byte-order mark.
150
+ lines = list(csv.reader(io.StringIO(text.lstrip("")), delimiter=delimiter))
151
+ lines = [line for line in lines if line] # skip blank lines
152
+ if not lines:
153
+ raise ValueError("CSV input is empty; expected a header row")
154
+ keys, body = lines[0], lines[1:]
155
+ rows: list[list] = []
156
+ for n, line in enumerate(body, start=2):
157
+ if len(line) > len(keys):
158
+ raise ValueError(f"CSV line {n} has {len(line)} fields, expected {len(keys)}")
159
+ values: list = [None if v == "" else v for v in line]
160
+ rows.append(values + [None] * (len(keys) - len(values)))
161
+ if parse_numbers:
162
+ for i in range(len(keys)):
163
+ # All or nothing per column, so a ZIP-code column holding both
164
+ # "00501" and "90210" stays text instead of mixing types.
165
+ parsed = [parse_number(row[i]) if row[i] is not None else None for row in rows]
166
+ if all(p is None or not isinstance(p, str) for p in parsed):
167
+ for row, value in zip(rows, parsed):
168
+ row[i] = value
169
+ return build_table(keys, rows, columns=columns, **options)
170
+
171
+
172
+ def from_dataframe(
173
+ df: Any,
174
+ columns: Optional[Sequence[Any]] = None,
175
+ *,
176
+ index: bool = False,
177
+ **options: Any,
178
+ ) -> "Table":
179
+ if not (hasattr(df, "columns") and hasattr(df, "itertuples")):
180
+ raise TypeError(f"Expected a pandas-like DataFrame, got {type(df).__name__}")
181
+ keys = list(df.columns)
182
+ rows = [list(row) for row in df.itertuples(index=index, name=None)]
183
+ if index:
184
+ index_key = df.index.name if df.index.name is not None else ""
185
+ keys = [index_key] + keys
186
+ if columns is not None and index_key not in columns:
187
+ columns = [index_key] + list(columns)
188
+ return build_table(keys, rows, columns=columns, **options)
189
+
190
+
191
+ def _check_keys(mapping: Mapping, keys: Sequence[Any], what: str) -> None:
192
+ unknown = [k for k in mapping if k not in keys]
193
+ if unknown:
194
+ raise ValueError(f"{what} names unknown column(s): {', '.join(map(repr, unknown))}")
195
+
196
+
197
+ def _numeric_column(values: Sequence[Any]) -> bool:
198
+ present = [v for v in values if not is_missing(v)]
199
+ return bool(present) and all(is_numeric(v) for v in present)
200
+
201
+
202
+ def _format(value: Any, column_format: Optional[CellFormat], default_format: Optional[CellFormat], missing: str) -> TextLike:
203
+ if is_missing(value):
204
+ return missing
205
+ if isinstance(value, (list, tuple)):
206
+ return value # already TextLike (mixed str/Inline content)
207
+ fmt = column_format
208
+ if fmt is None and is_numeric(value):
209
+ fmt = default_format # a single format applies to numbers only
210
+ if fmt is None:
211
+ return value if isinstance(value, (str, Inline)) else str(value)
212
+ if callable(fmt):
213
+ return fmt(value)
214
+ return fmt.format(value)
@@ -8,10 +8,15 @@ with the same methods.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from typing import Optional, Sequence
11
+ from collections.abc import Mapping
12
+ from pathlib import Path
13
+ from typing import IO, TYPE_CHECKING, Any, Iterable, Optional, Sequence, Union
12
14
 
13
15
  from .inline import InlineContent, TextLike, normalize
14
16
 
17
+ if TYPE_CHECKING:
18
+ from .data import Formats, Headers
19
+
15
20
 
16
21
  class Element:
17
22
  """Base class for all block elements."""
@@ -79,6 +84,85 @@ class Table(Element):
79
84
  raise ValueError(f"Invalid alignment: {a!r}")
80
85
  self.align = list(align) if align is not None else ["left"] * width
81
86
 
87
+ # -- construction from data ------------------------------------------
88
+ # Shared options: ``columns`` selects and orders columns; ``headers``
89
+ # relabels them (a list, or a column -> label mapping); ``formats`` is a
90
+ # format string / callable applied to every numeric cell, or a
91
+ # column -> format mapping applied to every value in that column;
92
+ # ``missing`` replaces None/NaN/NA; ``align=None`` right-aligns numeric
93
+ # columns and left-aligns the rest.
94
+
95
+ @classmethod
96
+ def from_records(
97
+ cls,
98
+ records: Iterable[Mapping[Any, Any]],
99
+ columns: Optional[Sequence[Any]] = None,
100
+ *,
101
+ headers: Headers = None,
102
+ formats: Formats = None,
103
+ align: Optional[Sequence[str]] = None,
104
+ missing: str = "",
105
+ caption: str = "",
106
+ ) -> "Table":
107
+ """Build a table from a sequence of dicts (keys become headers)."""
108
+ from .data import from_records
109
+
110
+ return from_records(
111
+ records, columns, headers=headers, formats=formats,
112
+ align=align, missing=missing, caption=caption,
113
+ )
114
+
115
+ @classmethod
116
+ def from_csv(
117
+ cls,
118
+ source: Union[str, Path, IO[str]],
119
+ columns: Optional[Sequence[str]] = None,
120
+ *,
121
+ delimiter: str = ",",
122
+ encoding: str = "utf-8",
123
+ parse_numbers: bool = True,
124
+ headers: Headers = None,
125
+ formats: Formats = None,
126
+ align: Optional[Sequence[str]] = None,
127
+ missing: str = "",
128
+ caption: str = "",
129
+ ) -> "Table":
130
+ """Build a table from a CSV file path or open text file.
131
+
132
+ The first row is the header. With ``parse_numbers=True`` numeric
133
+ fields become numbers (so formats and auto-alignment apply);
134
+ integers with leading zeros stay text.
135
+ """
136
+ from .data import from_csv
137
+
138
+ return from_csv(
139
+ source, columns, delimiter=delimiter, encoding=encoding,
140
+ parse_numbers=parse_numbers, headers=headers, formats=formats,
141
+ align=align, missing=missing, caption=caption,
142
+ )
143
+
144
+ @classmethod
145
+ def from_dataframe(
146
+ cls,
147
+ df: Any,
148
+ columns: Optional[Sequence[Any]] = None,
149
+ *,
150
+ index: bool = False,
151
+ headers: Headers = None,
152
+ formats: Formats = None,
153
+ align: Optional[Sequence[str]] = None,
154
+ missing: str = "",
155
+ caption: str = "",
156
+ ) -> "Table":
157
+ """Build a table from a pandas DataFrame (pandas is not required by
158
+ reportsmith itself). ``index=True`` adds the index as the first column."""
159
+ from .data import from_dataframe
160
+
161
+ return from_dataframe(
162
+ df, columns, index=index, headers=headers, formats=formats,
163
+ align=align, missing=missing, caption=caption,
164
+ )
165
+
82
166
 
83
167
  class Image(Element):
84
168
  def __init__(self, src: str, alt: str = "", title: str = ""):
@@ -108,6 +192,21 @@ class Raw(Element):
108
192
  self.format = format
109
193
 
110
194
 
195
+ class TableOfContents(Element):
196
+ """A table of contents listing the report's sections and headings.
197
+
198
+ Always covers the whole report, wherever it is placed. ``depth`` limits
199
+ how many heading levels are listed: 1 lists only top-level sections, 2
200
+ adds their subsections, and so on (up to 5).
201
+ """
202
+
203
+ def __init__(self, title: str = "Contents", depth: int = 3):
204
+ if not 1 <= depth <= 5:
205
+ raise ValueError(f"TOC depth must be 1-5, got {depth}")
206
+ self.title = title
207
+ self.depth = depth
208
+
209
+
111
210
  class Container(Element):
112
211
  """Mixin providing the fluent builder API shared by Report and Section."""
113
212
 
@@ -161,6 +260,33 @@ class Container(Element):
161
260
  self.children.append(Table(headers, rows, caption, align))
162
261
  return self
163
262
 
263
+ def table_from_records(
264
+ self,
265
+ records: Iterable[Mapping[Any, Any]],
266
+ columns: Optional[Sequence[Any]] = None,
267
+ **options: Any,
268
+ ) -> "Container":
269
+ """Add a table built from dicts; see :meth:`Table.from_records`."""
270
+ self.children.append(Table.from_records(records, columns, **options))
271
+ return self
272
+
273
+ def table_from_csv(
274
+ self,
275
+ source: Union[str, Path, IO[str]],
276
+ columns: Optional[Sequence[str]] = None,
277
+ **options: Any,
278
+ ) -> "Container":
279
+ """Add a table read from CSV; see :meth:`Table.from_csv`."""
280
+ self.children.append(Table.from_csv(source, columns, **options))
281
+ return self
282
+
283
+ def table_from_dataframe(
284
+ self, df: Any, columns: Optional[Sequence[Any]] = None, **options: Any
285
+ ) -> "Container":
286
+ """Add a table built from a DataFrame; see :meth:`Table.from_dataframe`."""
287
+ self.children.append(Table.from_dataframe(df, columns, **options))
288
+ return self
289
+
164
290
  def image(self, src: str, alt: str = "", title: str = "") -> "Container":
165
291
  self.children.append(Image(src, alt, title))
166
292
  return self
@@ -173,6 +299,11 @@ class Container(Element):
173
299
  self.children.append(Raw(content, format))
174
300
  return self
175
301
 
302
+ def toc(self, title: str = "Contents", depth: int = 3) -> "Container":
303
+ """Add a table of contents for the whole report."""
304
+ self.children.append(TableOfContents(title, depth))
305
+ return self
306
+
176
307
 
177
308
  class Section(Container):
178
309
  """A titled group of elements; nests to any depth.
@@ -17,8 +17,10 @@ from ..elements import (
17
17
  Raw,
18
18
  Section,
19
19
  Table,
20
+ TableOfContents,
20
21
  )
21
22
  from ..inline import Inline, InlineContent
23
+ from ..toc import TocEntry, build_tree, collect_entries, find_tocs, heading_anchors
22
24
 
23
25
  if TYPE_CHECKING:
24
26
  from ..report import Report
@@ -68,6 +70,12 @@ pre code { background: none; padding: 0; }
68
70
  blockquote { border-left: 4px solid var(--border); color: var(--muted); margin: 1em 0; padding: 0.1em 1em; }
69
71
  img { max-width: 100%; }
70
72
  hr { border: none; border-top: 1px solid var(--border); margin: 2em 0; }
73
+ nav.toc { border: 1px solid var(--border); border-radius: 8px; padding: 0.8em 1.2em; margin: 1.5em 0; }
74
+ nav.toc .toc-title { font-weight: 600; margin-bottom: 0.3em; }
75
+ nav.toc ul { list-style: none; margin: 0; padding-left: 1.2em; }
76
+ nav.toc > ul { padding-left: 0; }
77
+ nav.toc a { text-decoration: none; }
78
+ nav.toc a:hover { text-decoration: underline; }
71
79
  """
72
80
 
73
81
 
@@ -99,6 +107,10 @@ class HtmlRenderer:
99
107
  )
100
108
 
101
109
  def _body(self, report: "Report") -> str:
110
+ self._report = report
111
+ # Headings only get ids when there is a TOC to link to them, so
112
+ # output for reports without one is unchanged.
113
+ self._anchors = heading_anchors(report) if any(find_tocs(report)) else {}
102
114
  parts = [f"<h1>{escape(report.title)}</h1>\n"]
103
115
  meta = []
104
116
  if report.subtitle:
@@ -118,11 +130,11 @@ class HtmlRenderer:
118
130
  def _render_element(self, el: Element) -> str:
119
131
  if isinstance(el, Section):
120
132
  level = el._level
121
- out = [f"<h{level}>{self._inline(el.title_parts)}</h{level}>\n"]
133
+ out = [f"<h{level}{self._id(el)}>{self._inline(el.title_parts)}</h{level}>\n"]
122
134
  out.extend(self._render_element(child) for child in el.children)
123
135
  return "".join(out)
124
136
  if isinstance(el, Heading):
125
- return f"<h{el.level}>{self._inline(el.parts)}</h{el.level}>\n"
137
+ return f"<h{el.level}{self._id(el)}>{self._inline(el.parts)}</h{el.level}>\n"
126
138
  if isinstance(el, Paragraph):
127
139
  return f"<p>{self._inline(el.parts)}</p>\n"
128
140
  if isinstance(el, Quote):
@@ -144,8 +156,42 @@ class HtmlRenderer:
144
156
  return "<hr>\n"
145
157
  if isinstance(el, Raw):
146
158
  return el.content + "\n" if el.format in ("html", "both") else ""
159
+ if isinstance(el, TableOfContents):
160
+ return self._toc(el)
147
161
  raise TypeError(f"Cannot render element of type {type(el).__name__}")
148
162
 
163
+ def _id(self, el: Element) -> str:
164
+ anchor = self._anchors.get(id(el))
165
+ return f' id="{escape(anchor, quote=True)}"' if anchor else ""
166
+
167
+ def _toc(self, el: TableOfContents) -> str:
168
+ label = escape(el.title or "Contents", quote=True)
169
+ out = [f'<nav class="toc" aria-label="{label}">\n']
170
+ if el.title:
171
+ out.append(f'<div class="toc-title">{escape(el.title)}</div>\n')
172
+
173
+ def walk(entries: list[TocEntry]) -> None:
174
+ out.append("<ul>\n")
175
+ for entry in entries:
176
+ anchor = escape(self._anchors[id(entry.element)], quote=True)
177
+ # Links can't nest inside the entry's own <a>; keep their text.
178
+ parts = [
179
+ p.text if isinstance(p, Inline) and p.style == "link" else p
180
+ for p in entry.parts
181
+ ]
182
+ out.append(f'<li><a href="#{anchor}">{self._inline(parts)}</a>')
183
+ if entry.children:
184
+ out.append("\n")
185
+ walk(entry.children)
186
+ out.append("</li>\n")
187
+ out.append("</ul>\n")
188
+
189
+ entries = build_tree(collect_entries(self._report, el.depth))
190
+ if entries:
191
+ walk(entries)
192
+ out.append("</nav>\n")
193
+ return "".join(out)
194
+
149
195
  def _list(self, el: ListElement) -> str:
150
196
  tag = "ol" if el.ordered else "ul"
151
197
  out = [f"<{tag}>\n"]
@@ -16,6 +16,7 @@ from ..elements import (
16
16
  Raw,
17
17
  Section,
18
18
  Table,
19
+ TableOfContents,
19
20
  )
20
21
  from ..inline import Inline, InlineContent
21
22
 
@@ -171,6 +172,13 @@ class LatexRenderer:
171
172
  return "\\par\\noindent\\rule{\\linewidth}{0.4pt}\\par\n\n"
172
173
  if isinstance(el, Raw):
173
174
  return el.content + "\n\n" if el.format == "latex" else ""
175
+ if isinstance(el, TableOfContents):
176
+ # Grouped so the renamed heading stays local to this TOC. Our
177
+ # depth 1 (top-level sections) is LaTeX's tocdepth 1 (\section).
178
+ return (
179
+ f"{{\\renewcommand{{\\contentsname}}{{{escape_latex(el.title)}}}"
180
+ f"\\setcounter{{tocdepth}}{{{el.depth}}}\\tableofcontents}}\n\n"
181
+ )
174
182
  raise TypeError(f"Cannot render element of type {type(el).__name__}")
175
183
 
176
184
  def _code(self, el: CodeBlock) -> str:
@@ -16,8 +16,10 @@ from ..elements import (
16
16
  Raw,
17
17
  Section,
18
18
  Table,
19
+ TableOfContents,
19
20
  )
20
21
  from ..inline import Inline, InlineContent
22
+ from ..toc import TocEntry, build_tree, collect_entries, heading_anchors, plain_text
21
23
 
22
24
  if TYPE_CHECKING:
23
25
  from ..report import Report
@@ -29,6 +31,8 @@ class MarkdownRenderer:
29
31
  """Renders a Report to GitHub-flavored Markdown."""
30
32
 
31
33
  def render(self, report: "Report") -> str:
34
+ self._report = report
35
+ self._anchors = heading_anchors(report)
32
36
  blocks: list[str] = [f"# {report.title}"]
33
37
  meta = self._metadata_lines(report)
34
38
  if meta:
@@ -79,8 +83,27 @@ class MarkdownRenderer:
79
83
  return ["---"]
80
84
  if isinstance(el, Raw):
81
85
  return [el.content] if el.format in ("markdown", "both") else []
86
+ if isinstance(el, TableOfContents):
87
+ return self._toc(el)
82
88
  raise TypeError(f"Cannot render element of type {type(el).__name__}")
83
89
 
90
+ def _toc(self, el: TableOfContents) -> list[str]:
91
+ blocks = [f"**{el.title}**"] if el.title else []
92
+ lines: list[str] = []
93
+
94
+ def walk(entries: list[TocEntry], indent: int) -> None:
95
+ for entry in entries:
96
+ # Plain text: inline markup can't nest inside link text.
97
+ text = plain_text(entry.parts).replace("[", r"\[").replace("]", r"\]")
98
+ anchor = self._anchors[id(entry.element)]
99
+ lines.append(f"{' ' * indent}- [{text}](#{anchor})")
100
+ walk(entry.children, indent + 1)
101
+
102
+ walk(build_tree(collect_entries(self._report, el.depth)), 0)
103
+ if lines:
104
+ blocks.append("\n".join(lines))
105
+ return blocks
106
+
84
107
  def _list_lines(self, el: ListElement, indent: int) -> list[str]:
85
108
  lines: list[str] = []
86
109
  pad = " " * indent