Coverage for src/coursebook/templates.py: 94%

202 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-31 22:06 +0000

1"""Finds and prepares the templates and citation styles used for exports. 

2 

3Pandoc formats Word and PowerPoint files by copying the styles of a reference 

4document (a '.docx' or '.pptx' file). This module turns the templates that a 

5project names into reference documents: 

6 

7* Word: '.docx' files are used as is and '.dotx' templates are converted. If 

8 a project does not name a Word template, pandoc's default template is used 

9 with the `Formatting` of the style applied. 

10* PowerPoint: '.pptx' files are used as is and '.potx' templates and '.thmx' 

11 Office themes are converted. 

12 

13Citation Style Language ('.csl') files are found the same way as templates. 

14 

15A template is found by its path (relative to the project folder) or by its 

16name. A name is looked for first in the project folder and then among the 

17templates included with `coursebook` (such as 'chalkboard' and 

18'bluebook-law-review'). 

19 

20Contents: 

21 create_word_template: creates a Word reference document with formatting. 

22 find_template: returns the path to a template. 

23 get_pandoc_template: returns pandoc's default reference document. 

24 list_templates: returns the names of the included templates. 

25 prepare_template: converts a template into a reference document. 

26 _DOCUMENT: the start of the Word content types. 

27 _EXTENSIONS: the template file extensions for each kind of template. 

28 _HEADING_STYLES: the Word styles that are formatted as headings. 

29 _MAIN_CONTENT_TYPES: the content types that templates are converted from 

30 and to. 

31 _PRESENTATION: the start of the PowerPoint content types. 

32 _PRESENTATION_NAMESPACES: the XML namespaces of PowerPoint parts. 

33 _RELATIONSHIPS: the namespace of Office relationship types. 

34 _TEMPLATES_FOLDER: folder with the templates included with `coursebook`. 

35 _THEME_EXTRA_PARTS: the parts that a presentation needs but an Office 

36 theme does not include. 

37 _XML_DECLARATION: the declaration at the start of each XML part. 

38 

39""" 

40 

41from __future__ import annotations 

42 

43import pathlib 

44import re 

45import subprocess 

46import zipfile 

47from typing import TYPE_CHECKING 

48 

49import docx 

50import pypandoc 

51from docx.enum.text import WD_ALIGN_PARAGRAPH 

52from docx.oxml import OxmlElement 

53from docx.oxml.ns import qn 

54from docx.shared import Inches, Pt, RGBColor 

55 

56if TYPE_CHECKING: 

57 from docx.document import Document 

58 from docx.oxml.xmlchemy import BaseOxmlElement 

59 from docx.section import Section 

60 from docx.styles.style import ParagraphStyle 

61 

62 from . import styles 

63 

64_TEMPLATES_FOLDER: pathlib.Path = pathlib.Path(__file__).parent / 'templates' 

65_EXTENSIONS: dict[str, tuple[str, ...]] = { 

66 'csl': ('.csl',), 

67 'powerpoint': ('.pptx', '.potx', '.thmx'), 

68 'word': ('.docx', '.dotx')} 

69_PRESENTATION: str = ( 

70 'application/vnd.openxmlformats-officedocument.presentationml') 

71_DOCUMENT: str = ( 

72 'application/vnd.openxmlformats-officedocument.wordprocessingml') 

73_MAIN_CONTENT_TYPES: dict[str, tuple[str, str]] = { 

74 '.potx': (f'{_PRESENTATION}.template.main+xml', 

75 f'{_PRESENTATION}.presentation.main+xml'), 

76 '.dotx': (f'{_DOCUMENT}.template.main+xml', 

77 f'{_DOCUMENT}.document.main+xml')} 

78_RELATIONSHIPS: str = ( 

79 'http://schemas.openxmlformats.org/officeDocument/2006/relationships') 

80# Parts that a presentation needs but an Office theme does not include. Each 

81# value is the content, content type, and relationship type of the part. 

82_PRESENTATION_NAMESPACES: str = ( 

83 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' 

84 f'xmlns:r="{_RELATIONSHIPS}" ' 

85 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"') 

86_XML_DECLARATION: str = ( 

87 '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n') 

88_THEME_EXTRA_PARTS: dict[str, tuple[str, str, str]] = { 

89 'ppt/presProps.xml': ( 

90 f'{_XML_DECLARATION}<p:presentationPr {_PRESENTATION_NAMESPACES}/>', 

91 f'{_PRESENTATION}.presProps+xml', 

92 'presProps'), 

93 'ppt/viewProps.xml': ( 

94 f'{_XML_DECLARATION}<p:viewPr {_PRESENTATION_NAMESPACES}/>', 

95 f'{_PRESENTATION}.viewProps+xml', 

96 'viewProps'), 

97 'ppt/tableStyles.xml': ( 

98 (f'{_XML_DECLARATION}<a:tblStyleLst xmlns:a="http://schemas.' 

99 'openxmlformats.org/drawingml/2006/main" ' 

100 'def="{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"/>'), 

101 f'{_PRESENTATION}.tableStyles+xml', 

102 'tableStyles')} 

103_HEADING_STYLES: tuple[str, ...] = ( 

104 'Title', 'Subtitle', *(f'Heading {level}' for level in range(1, 10))) 

105 

106 

107""" Public Functions """ 

108 

109 

110def create_word_template( 

111 formatting: styles.Formatting, 

112 destination: str | pathlib.Path) -> pathlib.Path: 

113 """Creates a Word reference document with `formatting` applied. 

114 

115 The document starts as pandoc's default reference document. Footnotes are 

116 numbered continuously instead of restarting in each section (which 

117 LibreOffice treats as restarting at each top level heading). 

118 

119 Args: 

120 formatting: the formatting to apply. 

121 destination: path for the created document. 

122 

123 Returns: 

124 The path to the created document. 

125 

126 """ 

127 destination = pathlib.Path(destination) 

128 destination.write_bytes(get_pandoc_template('docx')) 

129 document = docx.Document(str(destination)) 

130 _apply_formatting(document, formatting) 

131 _number_footnotes_continuously(document) 

132 document.save(str(destination)) 

133 return destination 

134 

135 

136def find_template( 

137 name: str, 

138 application: str, 

139 folder: str | pathlib.Path) -> pathlib.Path: 

140 """Returns the path to the template for `name`. 

141 

142 Args: 

143 name: a path to a template (absolute or relative to `folder`) or the 

144 name of a template without its extension. 

145 application: 'word', 'powerpoint', or 'csl'. 

146 folder: the project folder. 

147 

148 Returns: 

149 The path to the template. 

150 

151 Raises: 

152 FileNotFoundError: if no template is found for `name`. 

153 ValueError: if `application` is not 'word', 'powerpoint', or 'csl' or 

154 the template has the wrong extension. 

155 

156 """ 

157 if application not in _EXTENSIONS: 

158 message = f'unknown application "{application}"' 

159 raise ValueError(message) 

160 extensions = _EXTENSIONS[application] 

161 path = pathlib.Path(folder) / pathlib.Path(name).expanduser() 

162 if path.suffix: 

163 if path.suffix.lower() not in extensions: 

164 message = ( 

165 f'{path.name} is not a {application} template (use ' 

166 f'{", ".join(extensions)})') 

167 raise ValueError(message) 

168 if path.is_file(): 

169 return path 

170 else: 

171 for base in (pathlib.Path(folder), _TEMPLATES_FOLDER / application): 

172 for extension in extensions: 

173 candidate = base / f'{name}{extension}' 

174 if candidate.is_file(): 

175 return candidate 

176 included = ', '.join(list_templates(application)) or 'none' 

177 message = ( 

178 f'{application} template "{name}" not found (included templates: ' 

179 f'{included})') 

180 raise FileNotFoundError(message) 

181 

182 

183def get_pandoc_template(extension: str) -> bytes: 

184 """Returns pandoc's default reference document. 

185 

186 Args: 

187 extension: 'docx' or 'pptx'. 

188 

189 Returns: 

190 The contents of the reference document. 

191 

192 """ 

193 # 'shell' is `False` (the default) so that `subprocess` is used securely. 

194 process = subprocess.run( # noqa: S603 

195 [pypandoc.get_pandoc_path(), '--print-default-data-file', 

196 f'reference.{extension}'], 

197 capture_output = True, 

198 check = True) 

199 return process.stdout 

200 

201 

202def list_templates(application: str) -> list[str]: 

203 """Returns the names of the templates included with `coursebook`. 

204 

205 Args: 

206 application: 'word', 'powerpoint', or 'csl'. 

207 

208 Returns: 

209 The names of the included templates, without their extensions. 

210 

211 """ 

212 folder = _TEMPLATES_FOLDER / application 

213 if not folder.is_dir(): 

214 return [] 

215 extensions = _EXTENSIONS.get(application, ()) 

216 return sorted( 

217 path.stem for path in folder.iterdir() 

218 if path.suffix.lower() in extensions) 

219 

220 

221def prepare_template( 

222 path: str | pathlib.Path, 

223 destination: str | pathlib.Path) -> pathlib.Path: 

224 """Converts a template into a reference document that pandoc can use. 

225 

226 Args: 

227 path: path to a '.docx', '.dotx', '.pptx', '.potx', or '.thmx' file. 

228 destination: folder for a converted template. 

229 

230 Returns: 

231 `path` if it is already a '.docx' or '.pptx' file. Otherwise, the 

232 path to the converted template in `destination`. 

233 

234 Raises: 

235 ValueError: if `path` does not have a supported extension. 

236 

237 """ 

238 path = pathlib.Path(path) 

239 suffix = path.suffix.lower() 

240 if suffix in {'.docx', '.pptx'}: 

241 return path 

242 extension = '.docx' if suffix == '.dotx' else '.pptx' 

243 destination = pathlib.Path(destination) 

244 destination.mkdir(parents = True, exist_ok = True) 

245 converted = destination / f'{path.stem}{extension}' 

246 if suffix in _MAIN_CONTENT_TYPES: 

247 _convert_office_template(path, converted) 

248 elif suffix == '.thmx': 

249 _convert_theme(path, converted) 

250 else: 

251 message = f'{path.name} is not a Word or PowerPoint template' 

252 raise ValueError(message) 

253 return converted 

254 

255 

256""" Private Functions """ 

257 

258 

259def _apply_formatting( 

260 document: Document, 

261 formatting: styles.Formatting) -> None: 

262 """Applies `formatting` to the styles of a Word document. 

263 

264 Args: 

265 document: the Word document to change. 

266 formatting: the formatting to apply. 

267 

268 """ 

269 document_styles = document.styles 

270 if formatting.body_font: 

271 _set_default_font(document, formatting.body_font) 

272 _set_font(document_styles['Normal'], formatting.body_font) 

273 if formatting.font_size: 

274 document_styles['Normal'].font.size = Pt(formatting.font_size) 

275 body = document_styles['Body Text'] 

276 if formatting.line_spacing: 

277 body.paragraph_format.line_spacing = formatting.line_spacing 

278 if formatting.first_line_indent: 

279 body.paragraph_format.first_line_indent = Inches( 

280 formatting.first_line_indent) 

281 # The first paragraph under a heading is traditionally not indented. 

282 first = document_styles['First Paragraph'].paragraph_format 

283 first.first_line_indent = Inches(0) 

284 footnote = document_styles['Footnote Text'] 

285 if formatting.footnote_size: 

286 footnote.font.size = Pt(formatting.footnote_size) 

287 footnote.paragraph_format.line_spacing = 1.0 

288 heading_font = formatting.heading_font or formatting.body_font 

289 for name in _HEADING_STYLES: 

290 style = document_styles[name] 

291 if heading_font: 

292 _set_font(style, heading_font) 

293 if formatting.heading_color: 

294 style.font.color.rgb = RGBColor.from_string( 

295 formatting.heading_color) 

296 top = document_styles['Heading 1'] 

297 if formatting.center_top_headings: 

298 top.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER 

299 if formatting.capitalize_top_headings: 

300 top.font.all_caps = True 

301 section = document.sections[0] 

302 if formatting.margins is not None: 

303 margin = Inches(formatting.margins) 

304 section.left_margin = section.right_margin = margin 

305 section.top_margin = section.bottom_margin = margin 

306 if formatting.page_numbers: 

307 _add_page_numbers(section) 

308 

309 

310def _add_page_numbers(section: Section) -> None: 

311 """Adds a centered page number to the footer of a Word section. 

312 

313 Args: 

314 section: the Word section to change. 

315 

316 """ 

317 paragraph = section.footer.paragraphs[0] 

318 paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER 

319 run = paragraph.add_run()._r 

320 begin = OxmlElement('w:fldChar') 

321 begin.set(qn('w:fldCharType'), 'begin') 

322 instruction = OxmlElement('w:instrText') 

323 instruction.set(qn('xml:space'), 'preserve') 

324 instruction.text = 'PAGE' 

325 end = OxmlElement('w:fldChar') 

326 end.set(qn('w:fldCharType'), 'end') 

327 run.extend([begin, instruction, end]) 

328 

329 

330def _convert_office_template( 

331 source: pathlib.Path, 

332 destination: pathlib.Path) -> None: 

333 """Converts a '.dotx' or '.potx' template to a '.docx' or '.pptx' file. 

334 

335 The only difference between a template and a document is the content type 

336 of its main part. 

337 

338 Args: 

339 source: path to the template. 

340 destination: path for the converted file. 

341 

342 """ 

343 old, new = _MAIN_CONTENT_TYPES[source.suffix.lower()] 

344 with ( 

345 zipfile.ZipFile(source) as original, 

346 zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as copy): 

347 for info in original.infolist(): 

348 data = original.read(info.filename) 

349 if info.filename == '[Content_Types].xml': 349 ↛ 351line 349 didn't jump to line 351 because the condition on line 349 was always true

350 data = data.replace(old.encode(), new.encode()) 

351 copy.writestr(info, data) 

352 

353 

354def _convert_theme(source: pathlib.Path, destination: pathlib.Path) -> None: 

355 """Converts a '.thmx' Office theme to a '.pptx' file. 

356 

357 An Office theme stores a presentation without slides in a 'theme' folder. 

358 A '.pptx' file stores it in a 'ppt' folder. So, the parts are moved, the 

359 parts that are only used by themes are dropped, and the parts that a 

360 presentation needs but a theme lacks are added. 

361 

362 Args: 

363 source: path to the '.thmx' file. 

364 destination: path for the '.pptx' file. 

365 

366 Raises: 

367 ValueError: if `source` is not an Office theme with a presentation. 

368 

369 """ 

370 with zipfile.ZipFile(source) as theme: 

371 names = theme.namelist() 

372 if 'theme/presentation.xml' not in names: 

373 message = f'{source.name} does not include a slide master' 

374 raise ValueError(message) 

375 with zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as deck: 

376 for name in names: 

377 data = theme.read(name) 

378 if name.startswith('theme/theme/') and ( 

379 'themeManager' in name or 'Thumbnail' in name): 

380 continue 

381 if name == '[Content_Types].xml': 

382 deck.writestr(name, _convert_theme_content_types(data)) 

383 elif name == '_rels/.rels': 

384 text = re.sub( 

385 r'Target="theme/theme/themeManager\.xml"', 

386 'Target="ppt/presentation.xml"', 

387 data.decode('utf-8')) 

388 deck.writestr(name, text) 

389 elif name == 'theme/_rels/presentation.xml.rels': 

390 deck.writestr( 

391 'ppt/_rels/presentation.xml.rels', 

392 _convert_theme_relationships(data)) 

393 elif name.startswith('theme/'): 

394 deck.writestr(f'ppt/{name.removeprefix("theme/")}', data) 

395 else: 

396 deck.writestr(name, data) 

397 for part, (content, _, _) in _THEME_EXTRA_PARTS.items(): 

398 deck.writestr(part, content) 

399 

400 

401def _convert_theme_content_types(data: bytes) -> str: 

402 """Converts the content types of an Office theme to a presentation's. 

403 

404 Args: 

405 data: the '[Content_Types].xml' part of a '.thmx' file. 

406 

407 Returns: 

408 The content types for the converted presentation. 

409 

410 """ 

411 text = re.sub( 

412 r'<Override PartName="/theme/theme/themeManager\.xml"[^>]*/>', 

413 '', 

414 data.decode('utf-8')) 

415 text = text.replace('PartName="/theme/', 'PartName="/ppt/') 

416 overrides = ''.join( 

417 f'<Override PartName="/{part}" ContentType="{content_type}"/>' 

418 for part, (_, content_type, _) in _THEME_EXTRA_PARTS.items()) 

419 return text.replace('</Types>', f'{overrides}</Types>') 

420 

421 

422def _convert_theme_relationships(data: bytes) -> str: 

423 """Adds the relationships that a presentation needs to an Office theme's. 

424 

425 Args: 

426 data: the 'theme/_rels/presentation.xml.rels' part of a '.thmx' file. 

427 

428 Returns: 

429 The relationships for the converted presentation. 

430 

431 """ 

432 relationships = [( 

433 f'<Relationship Id="rIdCourseTheme" Type="{_RELATIONSHIPS}/theme" ' 

434 'Target="theme/theme1.xml"/>')] 

435 for index, (part, (_, _, kind)) in enumerate(_THEME_EXTRA_PARTS.items()): 

436 target = part.removeprefix('ppt/') 

437 relationships.append( 

438 f'<Relationship Id="rIdCourse{index}" ' 

439 f'Type="{_RELATIONSHIPS}/{kind}" Target="{target}"/>') 

440 return data.decode('utf-8').replace( 

441 '</Relationships>', f'{"".join(relationships)}</Relationships>') 

442 

443 

444def _number_footnotes_continuously(document: Document) -> None: 

445 """Sets the footnotes of a Word document to be numbered continuously. 

446 

447 Args: 

448 document: the Word document to change. 

449 

450 """ 

451 properties = document.sections[0]._sectPr 

452 footnotes = properties.find(qn('w:footnotePr')) 

453 if footnotes is None: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true

454 footnotes = OxmlElement('w:footnotePr') 

455 references = [ 

456 child for child in properties 

457 if child.tag in {qn('w:headerReference'), qn('w:footerReference')}] 

458 if references: 

459 references[-1].addnext(footnotes) 

460 else: 

461 properties.insert(0, footnotes) 

462 restart = footnotes.find(qn('w:numRestart')) 

463 if restart is None: 463 ↛ 466line 463 didn't jump to line 466 because the condition on line 463 was never true

464 # 'w:numRestart' is the last element that a 'w:footnotePr' in a 

465 # section may have. 

466 restart = OxmlElement('w:numRestart') 

467 footnotes.append(restart) 

468 restart.set(qn('w:val'), 'continuous') 

469 

470 

471def _set_default_font(document: Document, font: str) -> None: 

472 """Sets the default font of a Word document. 

473 

474 Args: 

475 document: the Word document to change. 

476 font: the name of the font. 

477 

478 """ 

479 defaults = document.styles.element.find(qn('w:docDefaults')) 

480 if defaults is None: 480 ↛ 481line 480 didn't jump to line 481 because the condition on line 480 was never true

481 return 

482 properties = defaults.find(qn('w:rPrDefault')) 

483 if properties is None: 483 ↛ 484line 483 didn't jump to line 484 because the condition on line 483 was never true

484 return 

485 run = properties.find(qn('w:rPr')) 

486 fonts = None if run is None else run.find(qn('w:rFonts')) 

487 if fonts is not None: 487 ↛ exitline 487 didn't return from function '_set_default_font' because the condition on line 487 was always true

488 _replace_theme_fonts(fonts, font) 

489 

490 

491def _set_font(style: ParagraphStyle, font: str) -> None: 

492 """Sets the font of a Word style, replacing any theme fonts. 

493 

494 Word uses a theme font (such as 'majorHAnsi') instead of a named font when 

495 both are set. So, the theme fonts are removed. 

496 

497 Args: 

498 style: the Word style to change. 

499 font: the name of the font. 

500 

501 """ 

502 style.font.name = font 

503 fonts = style.element.rPr.find(qn('w:rFonts')) 

504 _replace_theme_fonts(fonts, font) 

505 

506 

507def _replace_theme_fonts(fonts: BaseOxmlElement, font: str) -> None: 

508 """Replaces the theme fonts in a 'w:rFonts' element with `font`. 

509 

510 Args: 

511 fonts: the 'w:rFonts' element. 

512 font: the name of the font. 

513 

514 """ 

515 for attribute in ('asciiTheme', 'hAnsiTheme', 'eastAsiaTheme', 'cstheme'): 

516 fonts.attrib.pop(qn(f'w:{attribute}'), None) 

517 for attribute in ('ascii', 'hAnsi', 'eastAsia', 'cs'): 

518 fonts.set(qn(f'w:{attribute}'), font)