Coverage for src/coursebook/assembly.py: 99%
54 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-31 22:06 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-31 22:06 +0000
1"""Combines the source files of a project into a single markdown document.
3The outline sets the order of the source files and adds a heading for each
4entry. The headings inside each source file are moved beneath the heading of
5its entry, and its footnote labels are made unique so that footnotes from
6different files do not collide.
8A source file's front matter may set:
10* `title`: the title used in place of the outline title in headings.
11* `visuals_in_notes`: whether to copy the images in its text into its notes
12 (overriding the project setting).
14Contents:
15 assemble_document: combines one section of every source into a document.
16 assemble_slides: combines the slides of every source into a slide deck.
17 _MAXIMUM_HEADING_LEVEL: the deepest heading level that markdown supports.
19"""
21from __future__ import annotations
23import dataclasses
24from typing import TYPE_CHECKING
26from . import markdown
28if TYPE_CHECKING:
29 from collections.abc import Mapping
31 from . import outline, sources, styles
33_MAXIMUM_HEADING_LEVEL: int = 6
36""" Public Functions """
39def assemble_document(
40 project_outline: outline.Outline,
41 project_sources: Mapping[str, sources.Source],
42 *,
43 section: str,
44 style: styles.Style,
45 include_empty: bool = True,
46 visuals_in_notes: bool = False) -> str:
47 """Combines one section of every source into a markdown document.
49 Each outline entry becomes a heading at the level of the entry (so a top
50 level entry is a level one heading). The headings in each source are
51 moved beneath the heading of its entry.
53 Args:
54 project_outline: the outline of the project.
55 project_sources: the sources of the project, with keys that are the
56 names of the outline entries.
57 section: the section to combine ('text' or 'notes').
58 style: the style of the project.
59 include_empty: whether to add headings for entries that have no
60 content in `section` (and no entries beneath them with content).
61 Defaults to `True`.
62 visuals_in_notes: whether to copy the images in the text of each
63 source into its notes when `section` is 'notes'. A source's front
64 matter may override this. Defaults to `False`.
66 Returns:
67 The combined markdown.
69 """
70 parts: list[str] = []
71 for entry in project_outline.walk():
72 if not include_empty and not _has_content(
73 entry, project_sources, section, visuals_in_notes):
74 continue
75 source = project_sources.get(entry.name)
76 level = min(entry.level + 1, _MAXIMUM_HEADING_LEVEL)
77 heading = style.format_heading(_titled(entry, source))
78 parts.append(f'{"#" * level} {heading}')
79 body = _get_body(source, section, visuals_in_notes)
80 if body:
81 body = markdown.namespace_footnotes(body, prefix = entry.name)
82 parts.append(markdown.shift_headings(body, levels = level))
83 return _join(parts)
86def assemble_slides(
87 project_outline: outline.Outline,
88 project_sources: Mapping[str, sources.Source],
89 *,
90 style: styles.Style,
91 section_slides: bool = True) -> str:
92 """Combines the slides of every source into a markdown slide deck.
94 The deck uses pandoc's slide level 2: each level two heading (or
95 horizontal rule) starts a new slide and each level one heading is a
96 section title slide. In a source file, each slide starts with a level one
97 heading (which becomes level two) or a horizontal rule ('---'). If the
98 slides of a source do not start with a heading, the first slide is given
99 the heading of its entry.
101 Args:
102 project_outline: the outline of the project.
103 project_sources: the sources of the project, with keys that are the
104 names of the outline entries.
105 style: the style of the project.
106 section_slides: whether to add a section title slide for each entry
107 that has slides (or entries beneath it with slides). Defaults to
108 `True`.
110 Returns:
111 The combined markdown.
113 """
114 parts: list[str] = []
115 for entry in project_outline.walk():
116 if not _has_content(entry, project_sources, 'slides'):
117 continue
118 source = project_sources.get(entry.name)
119 heading = style.format_heading(_titled(entry, source))
120 if section_slides: 120 ↛ 122line 120 didn't jump to line 122 because the condition on line 120 was always true
121 parts.append(f'# {heading}')
122 body = _get_body(source, 'slides')
123 if body:
124 if not body.lstrip().startswith('#'):
125 parts.append(f'## {heading}')
126 body = markdown.namespace_footnotes(body, prefix = entry.name)
127 parts.append(markdown.shift_headings(body, levels = 1))
128 return _join(parts)
131""" Private Functions """
134def _get_body(
135 source: sources.Source | None,
136 section: str,
137 visuals_in_notes: bool = False) -> str:
138 """Returns the markdown of `section` in `source`.
140 Args:
141 source: the source file, or `None` if the entry has no source file.
142 section: the section to return.
143 visuals_in_notes: whether to add the images in the text of `source`
144 to its notes when `section` is 'notes'. The front matter of
145 `source` may override this. Defaults to `False`.
147 Returns:
148 The markdown of the section, or an empty `str` if there is none.
150 """
151 if source is None:
152 return ''
153 body = source.get(section)
154 copy_visuals = bool(source.metadata.get(
155 'visuals_in_notes', visuals_in_notes))
156 if section == 'notes' and copy_visuals:
157 images = markdown.extract_images(source.get('text'))
158 body = '\n\n'.join(part for part in (body, *images) if part)
159 return body
162def _has_content(
163 entry: outline.OutlineEntry,
164 project_sources: Mapping[str, sources.Source],
165 section: str,
166 visuals_in_notes: bool = False) -> bool:
167 """Returns whether `entry` or an entry beneath it has `section` content.
169 Args:
170 entry: the outline entry.
171 project_sources: the sources of the project.
172 section: the section to check.
173 visuals_in_notes: whether images in the text count as notes. Defaults
174 to `False`.
176 Returns:
177 Whether there is any content in `section` for `entry` or the entries
178 beneath it.
180 """
181 return any(
182 _get_body(project_sources.get(item.name), section, visuals_in_notes)
183 for item in entry.walk())
186def _join(parts: list[str]) -> str:
187 """Joins the parts of a markdown document.
189 Args:
190 parts: the headings and bodies of the document, in order.
192 Returns:
193 The parts separated by blank lines, with a final newline.
195 """
196 return '\n\n'.join(parts) + '\n' if parts else ''
199def _titled(
200 entry: outline.OutlineEntry,
201 source: sources.Source | None) -> outline.OutlineEntry:
202 """Returns `entry` with the title from the front matter of `source`.
204 Args:
205 entry: the outline entry.
206 source: the source file of `entry`, if any.
208 Returns:
209 `entry`, or a copy with its title replaced if the front matter of
210 `source` sets `title`.
212 """
213 if source is not None and source.metadata.get('title'):
214 return dataclasses.replace(
215 entry, title = str(source.metadata['title']))
216 return entry