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

1"""Exports assembled markdown to Word, PowerPoint, PDF, and HTML files. 

2 

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`. 

6 

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]"`). 

9 

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. 

18 

19""" 

20 

21from __future__ import annotations 

22 

23import dataclasses 

24import os 

25import pathlib 

26import tempfile 

27from typing import TYPE_CHECKING, Any, ClassVar 

28 

29import pypandoc 

30import wonka 

31import yaml 

32 

33from . import markdown 

34 

35if TYPE_CHECKING: 

36 from collections.abc import Sequence 

37 

38 from . import styles 

39 

40 

41@dataclasses.dataclass 

42class ExportOptions: 

43 """Options shared by every exporter. 

44 

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`. 

61 

62 """ 

63 

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 

73 

74 

75@dataclasses.dataclass 

76class Exporter(wonka.AutoRegistrar): 

77 """Base class for exporters. 

78 

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. 

89 

90 """ 

91 

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') 

97 

98 """ Public Methods """ 

99 

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`. 

106 

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`. 

112 

113 Returns: 

114 The path to the exported file. 

115 

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 

126 

127 def get_arguments(self, options: ExportOptions) -> list[str]: 

128 """Returns the pandoc command line arguments for `options`. 

129 

130 Args: 

131 options: export options. 

132 

133 Returns: 

134 The arguments to pass to pandoc. 

135 

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 

150 

151 def get_metadata(self, options: ExportOptions) -> dict[str, Any]: 

152 """Returns the metadata to add to the start of the document. 

153 

154 Args: 

155 options: export options. 

156 

157 Returns: 

158 The metadata in `options`. 

159 

160 """ 

161 return dict(options.metadata) 

162 

163 

164@dataclasses.dataclass 

165class PowerPointExporter(Exporter): 

166 """Exports a slide deck to a PowerPoint file. 

167 

168 Attributes: 

169 slide_level: the heading level that starts a new slide. Headings above 

170 it become section title slides. 

171 

172 """ 

173 

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 

180 

181 """ Public Methods """ 

182 

183 def get_arguments(self, options: ExportOptions) -> list[str]: 

184 """Returns the pandoc command line arguments for `options`. 

185 

186 Args: 

187 options: export options. `table_of_contents` is ignored. 

188 

189 Returns: 

190 The arguments to pass to pandoc. 

191 

192 """ 

193 arguments = super().get_arguments(options) 

194 arguments.append(f'--slide-level={self.slide_level}') 

195 return arguments 

196 

197 

198@dataclasses.dataclass 

199class WordExporter(Exporter): 

200 """Exports a document to a Word file. 

201 

202 Attributes: 

203 toc_depth: the deepest heading level in the table of contents. 

204 

205 """ 

206 

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 

212 

213 """ Public Methods """ 

214 

215 def get_arguments(self, options: ExportOptions) -> list[str]: 

216 """Returns the pandoc command line arguments for `options`. 

217 

218 Args: 

219 options: export options. 

220 

221 Returns: 

222 The arguments to pass to pandoc. 

223 

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 

229 

230 

231@dataclasses.dataclass 

232class HtmlExporter(Exporter): 

233 """Exports a document to a single HTML file. 

234 

235 Images and styles are included in the file, so it can be shared or posted 

236 on its own. 

237 

238 Attributes: 

239 toc_depth: the deepest heading level in the table of contents. 

240 

241 """ 

242 

243 aliases: ClassVar[tuple[str, ...]] = ('html', 'web') 

244 extension: ClassVar[str] = 'html' 

245 pandoc_format: ClassVar[str] = 'html5' 

246 toc_depth: ClassVar[int] = 3 

247 

248 """ Public Methods """ 

249 

250 def get_arguments(self, options: ExportOptions) -> list[str]: 

251 """Returns the pandoc command line arguments for `options`. 

252 

253 Math is written as MathML so that pandoc does not need to download a 

254 math script to include in the file. 

255 

256 Args: 

257 options: export options. `template` is ignored. 

258 

259 Returns: 

260 The arguments to pass to pandoc. 

261 

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 

269 

270 def get_metadata(self, options: ExportOptions) -> dict[str, Any]: 

271 """Returns the metadata, with the formatting for pandoc's template. 

272 

273 Args: 

274 options: export options. 

275 

276 Returns: 

277 The metadata in `options` and the template variables and styles 

278 for `options.formatting`. 

279 

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 

314 

315 

316@dataclasses.dataclass 

317class PdfExporter(Exporter): 

318 """Exports a document to a PDF file with Typst. 

319 

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. 

323 

324 Attributes: 

325 paper_size: the Typst name of the paper size. 

326 toc_depth: the deepest heading level in the table of contents. 

327 

328 """ 

329 

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 

335 

336 """ Public Methods """ 

337 

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`. 

344 

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`. 

350 

351 Returns: 

352 The path to the exported file. 

353 

354 Raises: 

355 ImportError: if the `typst` package is not installed. 

356 

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 

380 

381 def get_arguments(self, options: ExportOptions) -> list[str]: 

382 """Returns the pandoc command line arguments for `options`. 

383 

384 Args: 

385 options: export options. `template` is ignored. 

386 

387 Returns: 

388 The arguments to pass to pandoc. 

389 

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 

397 

398 def get_metadata(self, options: ExportOptions) -> dict[str, Any]: 

399 """Returns the metadata, with the formatting for pandoc's template. 

400 

401 Args: 

402 options: export options. 

403 

404 Returns: 

405 The metadata in `options` and the template variables and Typst 

406 rules for `options.formatting`. 

407 

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 

450 

451 

452""" Public Functions """ 

453 

454 

455def get_exporter(name: str) -> Exporter: 

456 """Returns the exporter with `name`. 

457 

458 Args: 

459 name: the name of an `Exporter` subclass or one of its aliases (such as 

460 'word' or 'PowerPoint'). 

461 

462 Returns: 

463 An instance of the exporter. 

464 

465 Raises: 

466 ValueError: if no exporter has `name`. 

467 

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]() 

483 

484 

485""" Private Functions """ 

486 

487 

488def _add_metadata(text: str, metadata: dict[str, Any]) -> str: 

489 """Adds a YAML metadata block with `metadata` to the start of `text`. 

490 

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`). 

494 

495 Args: 

496 text: pandoc markdown. 

497 metadata: the metadata to add. Items with empty values are left out. 

498 

499 Returns: 

500 `text` with the metadata block, or `text` unchanged if there is no 

501 metadata. 

502 

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}' 

513 

514 

515def _list_paths(paths: Sequence[pathlib.Path]) -> str: 

516 """Returns `paths` as a single search path. 

517 

518 Args: 

519 paths: folders to search. 

520 

521 Returns: 

522 The folders separated by the operating system's path separator. 

523 

524 """ 

525 return os.pathsep.join(str(path) for path in paths) 

526 

527 

528class _MetadataDumper(yaml.SafeDumper): 

529 """YAML dumper that writes multi-line strings as literal blocks. 

530 

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. 

535 

536 """ 

537 

538 

539def _represent_string(dumper: yaml.SafeDumper, value: str) -> yaml.Node: 

540 """Represents `value` as a literal block if it has more than one line. 

541 

542 Args: 

543 dumper: the YAML dumper. 

544 value: the string to represent. 

545 

546 Returns: 

547 The YAML node for `value`. 

548 

549 """ 

550 style = '|' if '\n' in value else None 

551 return dumper.represent_scalar( 

552 'tag:yaml.org,2002:str', value, style = style) 

553 

554 

555_MetadataDumper.add_representer(str, _represent_string)