Coverage for src/coursebook/exporters.py: 90%
203 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"""Exports assembled markdown to Word, PowerPoint, PDF, and HTML files.
3The "Text Format", "Slides Format", and "Notes Format" settings of a project
4choose exporters by name. Every subclass of `Exporter` is registered
5automatically, so other formats can be added by subclassing `Exporter`.
7PDF files are made with [Typst](https://typst.app/) instead of LaTeX, so they
8only need the `typst` package (installed with `pip install "coursebook[pdf]"`).
10Contents:
11 ExportOptions: options shared by every exporter.
12 Exporter: base class for exporters.
13 HtmlExporter: exports a document to a single HTML file.
14 PdfExporter: exports a document to a PDF file.
15 PowerPointExporter: exports a slide deck to a PowerPoint file.
16 WordExporter: exports a document to a Word file.
17 get_exporter: returns the exporter with a name.
19"""
21from __future__ import annotations
23import dataclasses
24import os
25import pathlib
26import tempfile
27from typing import TYPE_CHECKING, Any, ClassVar
29import pypandoc
30import wonka
31import yaml
33from . import markdown
35if TYPE_CHECKING:
36 from collections.abc import Sequence
38 from . import styles
41@dataclasses.dataclass
42class ExportOptions:
43 """Options shared by every exporter.
45 Args:
46 template: path to the reference document that pandoc copies styles
47 from. Defaults to `None`, which uses pandoc's default.
48 metadata: document metadata (such as 'title' and 'author') that pandoc
49 uses for the title page or title slide. Defaults to an empty
50 `dict`.
51 resource_paths: folders that pandoc searches for images. Defaults to
52 an empty `list`.
53 bibliography: paths to bibliography files. If any are given, pandoc
54 formats citations (such as '[@smith2020]'). Defaults to an empty
55 `list`.
56 csl: path to a Citation Style Language file. Defaults to `None`.
57 table_of_contents: whether to add a table of contents. Defaults to
58 `False`.
59 formatting: the formatting of the style, which PDF and HTML exporters
60 pass to pandoc's templates. Defaults to `None`.
62 """
64 template: pathlib.Path | None = None
65 metadata: dict[str, Any] = dataclasses.field(default_factory = dict)
66 resource_paths: list[pathlib.Path] = dataclasses.field(
67 default_factory = list)
68 bibliography: list[pathlib.Path] = dataclasses.field(
69 default_factory = list)
70 csl: pathlib.Path | None = None
71 table_of_contents: bool = False
72 formatting: styles.Formatting | None = None
75@dataclasses.dataclass
76class Exporter(wonka.AutoRegistrar):
77 """Base class for exporters.
79 Attributes:
80 aliases: names (in lower case with underscores) that choose this
81 exporter in a project file.
82 application: the application whose templates the exporter uses
83 ('word' or 'powerpoint'), or an empty `str` if it uses none.
84 extension: the file extension of exported files, without a period.
85 pandoc_format: the name of the output format in pandoc.
86 registry: every `Exporter` subclass, with keys that are the snake case
87 names of the classes.
88 sections: the sections that the exporter can export.
90 """
92 aliases: ClassVar[tuple[str, ...]] = ()
93 application: ClassVar[str] = ''
94 extension: ClassVar[str] = ''
95 pandoc_format: ClassVar[str] = ''
96 sections: ClassVar[tuple[str, ...]] = ('text', 'notes')
98 """ Public Methods """
100 def export(
101 self,
102 text: str,
103 path: pathlib.Path,
104 options: ExportOptions | None = None) -> pathlib.Path:
105 """Exports markdown `text` to the file at `path`.
107 Args:
108 text: pandoc markdown.
109 path: path for the exported file.
110 options: export options. Defaults to `None`, which uses the
111 defaults of `ExportOptions`.
113 Returns:
114 The path to the exported file.
116 """
117 options = options or ExportOptions()
118 text = _add_metadata(text, self.get_metadata(options))
119 pypandoc.convert_text(
120 text,
121 to = self.pandoc_format,
122 format = 'markdown',
123 outputfile = str(path),
124 extra_args = self.get_arguments(options))
125 return path
127 def get_arguments(self, options: ExportOptions) -> list[str]:
128 """Returns the pandoc command line arguments for `options`.
130 Args:
131 options: export options.
133 Returns:
134 The arguments to pass to pandoc.
136 """
137 arguments: list[str] = []
138 if options.template is not None:
139 arguments.append(f'--reference-doc={options.template}')
140 if options.resource_paths: 140 ↛ 143line 140 didn't jump to line 143 because the condition on line 140 was always true
141 folders = _list_paths(options.resource_paths)
142 arguments.append(f'--resource-path={folders}')
143 if options.bibliography:
144 arguments.append('--citeproc')
145 arguments.extend(
146 f'--bibliography={path}' for path in options.bibliography)
147 if options.csl is not None: 147 ↛ 149line 147 didn't jump to line 149 because the condition on line 147 was always true
148 arguments.append(f'--csl={options.csl}')
149 return arguments
151 def get_metadata(self, options: ExportOptions) -> dict[str, Any]:
152 """Returns the metadata to add to the start of the document.
154 Args:
155 options: export options.
157 Returns:
158 The metadata in `options`.
160 """
161 return dict(options.metadata)
164@dataclasses.dataclass
165class PowerPointExporter(Exporter):
166 """Exports a slide deck to a PowerPoint file.
168 Attributes:
169 slide_level: the heading level that starts a new slide. Headings above
170 it become section title slides.
172 """
174 aliases: ClassVar[tuple[str, ...]] = ('powerpoint', 'pptx')
175 application: ClassVar[str] = 'powerpoint'
176 extension: ClassVar[str] = 'pptx'
177 pandoc_format: ClassVar[str] = 'pptx'
178 sections: ClassVar[tuple[str, ...]] = ('slides',)
179 slide_level: ClassVar[int] = 2
181 """ Public Methods """
183 def get_arguments(self, options: ExportOptions) -> list[str]:
184 """Returns the pandoc command line arguments for `options`.
186 Args:
187 options: export options. `table_of_contents` is ignored.
189 Returns:
190 The arguments to pass to pandoc.
192 """
193 arguments = super().get_arguments(options)
194 arguments.append(f'--slide-level={self.slide_level}')
195 return arguments
198@dataclasses.dataclass
199class WordExporter(Exporter):
200 """Exports a document to a Word file.
202 Attributes:
203 toc_depth: the deepest heading level in the table of contents.
205 """
207 aliases: ClassVar[tuple[str, ...]] = ('word', 'docx')
208 application: ClassVar[str] = 'word'
209 extension: ClassVar[str] = 'docx'
210 pandoc_format: ClassVar[str] = 'docx'
211 toc_depth: ClassVar[int] = 3
213 """ Public Methods """
215 def get_arguments(self, options: ExportOptions) -> list[str]:
216 """Returns the pandoc command line arguments for `options`.
218 Args:
219 options: export options.
221 Returns:
222 The arguments to pass to pandoc.
224 """
225 arguments = super().get_arguments(options)
226 if options.table_of_contents:
227 arguments.extend(['--toc', f'--toc-depth={self.toc_depth}'])
228 return arguments
231@dataclasses.dataclass
232class HtmlExporter(Exporter):
233 """Exports a document to a single HTML file.
235 Images and styles are included in the file, so it can be shared or posted
236 on its own.
238 Attributes:
239 toc_depth: the deepest heading level in the table of contents.
241 """
243 aliases: ClassVar[tuple[str, ...]] = ('html', 'web')
244 extension: ClassVar[str] = 'html'
245 pandoc_format: ClassVar[str] = 'html5'
246 toc_depth: ClassVar[int] = 3
248 """ Public Methods """
250 def get_arguments(self, options: ExportOptions) -> list[str]:
251 """Returns the pandoc command line arguments for `options`.
253 Math is written as MathML so that pandoc does not need to download a
254 math script to include in the file.
256 Args:
257 options: export options. `template` is ignored.
259 Returns:
260 The arguments to pass to pandoc.
262 """
263 options = dataclasses.replace(options, template = None)
264 arguments = super().get_arguments(options)
265 arguments.extend(['--standalone', '--embed-resources', '--mathml'])
266 if options.table_of_contents:
267 arguments.extend(['--toc', f'--toc-depth={self.toc_depth}'])
268 return arguments
270 def get_metadata(self, options: ExportOptions) -> dict[str, Any]:
271 """Returns the metadata, with the formatting for pandoc's template.
273 Args:
274 options: export options.
276 Returns:
277 The metadata in `options` and the template variables and styles
278 for `options.formatting`.
280 """
281 metadata = super().get_metadata(options)
282 formatting = options.formatting
283 if formatting is None:
284 return metadata
285 if formatting.body_font: 285 ↛ 289line 285 didn't jump to line 289 because the condition on line 285 was always true
286 # Pandoc reads metadata as markdown and would curl quotation marks,
287 # so the font name is not quoted (which CSS allows).
288 metadata['mainfont'] = f'{formatting.body_font}, serif'
289 if formatting.font_size: 289 ↛ 291line 289 didn't jump to line 291 because the condition on line 289 was always true
290 metadata['fontsize'] = f'{formatting.font_size}pt'
291 if formatting.line_spacing: 291 ↛ 293line 291 didn't jump to line 293 because the condition on line 291 was always true
292 metadata['linestretch'] = formatting.line_spacing
293 rules = []
294 if formatting.heading_font: 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true
295 rules.append(
296 f'h1, h2, h3, h4, h5, h6 {{ font-family: '
297 f'"{formatting.heading_font}", serif; }}')
298 if formatting.heading_color:
299 rules.append(
300 f'h1, h2, h3, h4, h5, h6, .title {{ color: '
301 f'#{formatting.heading_color}; }}')
302 if formatting.first_line_indent:
303 rules.append(
304 f'p + p {{ text-indent: {formatting.first_line_indent}in; }}')
305 if formatting.center_top_headings:
306 rules.append('h1 { text-align: center; }')
307 if formatting.capitalize_top_headings:
308 rules.append('h1 { text-transform: uppercase; }')
309 if rules:
310 styles = '\n'.join(rules)
311 metadata['header-includes'] = (
312 f'```{{=html}}\n<style>\n{styles}\n</style>\n```\n')
313 return metadata
316@dataclasses.dataclass
317class PdfExporter(Exporter):
318 """Exports a document to a PDF file with Typst.
320 Pandoc converts the document to Typst, and the `typst` package turns that
321 into a PDF file. Fonts must be installed on the computer; Typst uses its
322 own fonts in place of missing ones.
324 Attributes:
325 paper_size: the Typst name of the paper size.
326 toc_depth: the deepest heading level in the table of contents.
328 """
330 aliases: ClassVar[tuple[str, ...]] = ('pdf',)
331 extension: ClassVar[str] = 'pdf'
332 pandoc_format: ClassVar[str] = 'typst'
333 paper_size: ClassVar[str] = 'us-letter'
334 toc_depth: ClassVar[int] = 3
336 """ Public Methods """
338 def export(
339 self,
340 text: str,
341 path: pathlib.Path,
342 options: ExportOptions | None = None) -> pathlib.Path:
343 """Exports markdown `text` to the PDF file at `path`.
345 Args:
346 text: pandoc markdown.
347 path: path for the exported file.
348 options: export options. Defaults to `None`, which uses the
349 defaults of `ExportOptions`.
351 Returns:
352 The path to the exported file.
354 Raises:
355 ImportError: if the `typst` package is not installed.
357 """
358 try:
359 import typst # noqa: PLC0415
360 except ImportError as error:
361 message = (
362 'PDF export needs the "typst" package (install it with '
363 'pip install "coursebook[pdf]")')
364 raise ImportError(message) from error
365 options = options or ExportOptions()
366 with tempfile.TemporaryDirectory() as temporary:
367 folder = pathlib.Path(temporary)
368 source = folder / 'document.typ'
369 # Pandoc runs in the temporary folder so that the images it copies
370 # there have paths that Typst can find.
371 pypandoc.convert_text(
372 _add_metadata(text, self.get_metadata(options)),
373 to = self.pandoc_format,
374 format = 'markdown',
375 outputfile = str(source),
376 extra_args = self.get_arguments(options),
377 cworkdir = temporary)
378 typst.compile(str(source), output = str(path), root = temporary)
379 return path
381 def get_arguments(self, options: ExportOptions) -> list[str]:
382 """Returns the pandoc command line arguments for `options`.
384 Args:
385 options: export options. `template` is ignored.
387 Returns:
388 The arguments to pass to pandoc.
390 """
391 options = dataclasses.replace(options, template = None)
392 arguments = super().get_arguments(options)
393 arguments.extend(['--standalone', '--extract-media=media'])
394 if options.table_of_contents: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true
395 arguments.extend(['--toc', f'--toc-depth={self.toc_depth}'])
396 return arguments
398 def get_metadata(self, options: ExportOptions) -> dict[str, Any]:
399 """Returns the metadata, with the formatting for pandoc's template.
401 Args:
402 options: export options.
404 Returns:
405 The metadata in `options` and the template variables and Typst
406 rules for `options.formatting`.
408 """
409 metadata = super().get_metadata(options)
410 metadata['papersize'] = self.paper_size
411 formatting = options.formatting
412 if formatting is None: 412 ↛ 413line 412 didn't jump to line 413 because the condition on line 412 was never true
413 return metadata
414 if formatting.body_font: 414 ↛ 416line 414 didn't jump to line 416 because the condition on line 414 was always true
415 metadata['mainfont'] = formatting.body_font
416 if formatting.font_size: 416 ↛ 418line 416 didn't jump to line 418 because the condition on line 416 was always true
417 metadata['fontsize'] = f'{formatting.font_size}pt'
418 if formatting.line_spacing: 418 ↛ 420line 418 didn't jump to line 420 because the condition on line 418 was always true
419 metadata['linestretch'] = formatting.line_spacing
420 if formatting.margins is not None: 420 ↛ 423line 420 didn't jump to line 423 because the condition on line 420 was always true
421 margin = f'{formatting.margins}in'
422 metadata['margin'] = {'x': margin, 'y': margin}
423 if formatting.page_numbers: 423 ↛ 425line 423 didn't jump to line 425 because the condition on line 423 was always true
424 metadata['page-numbering'] = '1'
425 rules = []
426 if formatting.heading_font: 426 ↛ 427line 426 didn't jump to line 427 because the condition on line 426 was never true
427 rules.append(
428 f'#show heading: set text(font: "{formatting.heading_font}")')
429 if formatting.heading_color: 429 ↛ 433line 429 didn't jump to line 433 because the condition on line 429 was always true
430 rules.append(
431 f'#show heading: set text(fill: '
432 f'rgb("#{formatting.heading_color}"))')
433 if formatting.first_line_indent: 433 ↛ 437line 433 didn't jump to line 437 because the condition on line 433 was always true
434 rules.append(
435 f'#set par(first-line-indent: '
436 f'{formatting.first_line_indent}in)')
437 if formatting.footnote_size: 437 ↛ 441line 437 didn't jump to line 441 because the condition on line 437 was always true
438 rules.append(
439 f'#show footnote.entry: set text(size: '
440 f'{formatting.footnote_size}pt)')
441 if formatting.center_top_headings: 441 ↛ 443line 441 didn't jump to line 443 because the condition on line 441 was always true
442 rules.append('#show heading.where(level: 1): set align(center)')
443 if formatting.capitalize_top_headings: 443 ↛ 445line 443 didn't jump to line 445 because the condition on line 443 was always true
444 rules.append('#show heading.where(level: 1): upper')
445 if rules: 445 ↛ 449line 445 didn't jump to line 449 because the condition on line 445 was always true
446 typst_rules = '\n'.join(rules)
447 metadata['header-includes'] = (
448 f'```{{=typst}}\n{typst_rules}\n```\n')
449 return metadata
452""" Public Functions """
455def get_exporter(name: str) -> Exporter:
456 """Returns the exporter with `name`.
458 Args:
459 name: the name of an `Exporter` subclass or one of its aliases (such as
460 'word' or 'PowerPoint').
462 Returns:
463 An instance of the exporter.
465 Raises:
466 ValueError: if no exporter has `name`.
468 """
469 key = markdown.normalize_name(name)
470 choices: dict[str, type[Exporter]] = {}
471 for registered_name, registered in Exporter.registry.items():
472 choices[str(registered_name)] = registered
473 for alias in registered.aliases:
474 choices.setdefault(alias, registered)
475 choices.pop('exporter', None)
476 if key not in choices:
477 known = ', '.join(sorted(
478 alias for exporter in choices.values()
479 for alias in exporter.aliases))
480 message = f'unknown format "{name}" (use one of: {known})'
481 raise ValueError(message)
482 return choices[key]()
485""" Private Functions """
488def _add_metadata(text: str, metadata: dict[str, Any]) -> str:
489 """Adds a YAML metadata block with `metadata` to the start of `text`.
491 A metadata block is used instead of pandoc's '--metadata' argument because
492 pandoc reads values of that argument as YAML (so a title of 'No' would
493 become `False`).
495 Args:
496 text: pandoc markdown.
497 metadata: the metadata to add. Items with empty values are left out.
499 Returns:
500 `text` with the metadata block, or `text` unchanged if there is no
501 metadata.
503 """
504 values = {key: value for key, value in metadata.items() if value}
505 if not values: 505 ↛ 506line 505 didn't jump to line 506 because the condition on line 505 was never true
506 return text
507 block = yaml.dump(
508 values,
509 Dumper = _MetadataDumper,
510 allow_unicode = True,
511 sort_keys = False)
512 return f'---\n{block}---\n\n{text}'
515def _list_paths(paths: Sequence[pathlib.Path]) -> str:
516 """Returns `paths` as a single search path.
518 Args:
519 paths: folders to search.
521 Returns:
522 The folders separated by the operating system's path separator.
524 """
525 return os.pathsep.join(str(path) for path in paths)
528class _MetadataDumper(yaml.SafeDumper):
529 """YAML dumper that writes multi-line strings as literal blocks.
531 Pandoc reads metadata strings as markdown. A raw block (such as Typst
532 rules in 'header-includes') is only recognized if its lines and final
533 newline are kept, so multi-line strings are written in the literal ('|')
534 style.
536 """
539def _represent_string(dumper: yaml.SafeDumper, value: str) -> yaml.Node:
540 """Represents `value` as a literal block if it has more than one line.
542 Args:
543 dumper: the YAML dumper.
544 value: the string to represent.
546 Returns:
547 The YAML node for `value`.
549 """
550 style = '|' if '\n' in value else None
551 return dumper.represent_scalar(
552 'tag:yaml.org,2002:str', value, style = style)
555_MetadataDumper.add_representer(str, _represent_string)