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
« 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.
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:
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.
13Citation Style Language ('.csl') files are found the same way as templates.
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').
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.
39"""
41from __future__ import annotations
43import pathlib
44import re
45import subprocess
46import zipfile
47from typing import TYPE_CHECKING
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
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
62 from . import styles
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)))
107""" Public Functions """
110def create_word_template(
111 formatting: styles.Formatting,
112 destination: str | pathlib.Path) -> pathlib.Path:
113 """Creates a Word reference document with `formatting` applied.
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).
119 Args:
120 formatting: the formatting to apply.
121 destination: path for the created document.
123 Returns:
124 The path to the created document.
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
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`.
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.
148 Returns:
149 The path to the template.
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.
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)
183def get_pandoc_template(extension: str) -> bytes:
184 """Returns pandoc's default reference document.
186 Args:
187 extension: 'docx' or 'pptx'.
189 Returns:
190 The contents of the reference document.
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
202def list_templates(application: str) -> list[str]:
203 """Returns the names of the templates included with `coursebook`.
205 Args:
206 application: 'word', 'powerpoint', or 'csl'.
208 Returns:
209 The names of the included templates, without their extensions.
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)
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.
226 Args:
227 path: path to a '.docx', '.dotx', '.pptx', '.potx', or '.thmx' file.
228 destination: folder for a converted template.
230 Returns:
231 `path` if it is already a '.docx' or '.pptx' file. Otherwise, the
232 path to the converted template in `destination`.
234 Raises:
235 ValueError: if `path` does not have a supported extension.
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
256""" Private Functions """
259def _apply_formatting(
260 document: Document,
261 formatting: styles.Formatting) -> None:
262 """Applies `formatting` to the styles of a Word document.
264 Args:
265 document: the Word document to change.
266 formatting: the formatting to apply.
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)
310def _add_page_numbers(section: Section) -> None:
311 """Adds a centered page number to the footer of a Word section.
313 Args:
314 section: the Word section to change.
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])
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.
335 The only difference between a template and a document is the content type
336 of its main part.
338 Args:
339 source: path to the template.
340 destination: path for the converted file.
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)
354def _convert_theme(source: pathlib.Path, destination: pathlib.Path) -> None:
355 """Converts a '.thmx' Office theme to a '.pptx' file.
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.
362 Args:
363 source: path to the '.thmx' file.
364 destination: path for the '.pptx' file.
366 Raises:
367 ValueError: if `source` is not an Office theme with a presentation.
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)
401def _convert_theme_content_types(data: bytes) -> str:
402 """Converts the content types of an Office theme to a presentation's.
404 Args:
405 data: the '[Content_Types].xml' part of a '.thmx' file.
407 Returns:
408 The content types for the converted presentation.
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>')
422def _convert_theme_relationships(data: bytes) -> str:
423 """Adds the relationships that a presentation needs to an Office theme's.
425 Args:
426 data: the 'theme/_rels/presentation.xml.rels' part of a '.thmx' file.
428 Returns:
429 The relationships for the converted presentation.
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>')
444def _number_footnotes_continuously(document: Document) -> None:
445 """Sets the footnotes of a Word document to be numbered continuously.
447 Args:
448 document: the Word document to change.
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')
471def _set_default_font(document: Document, font: str) -> None:
472 """Sets the default font of a Word document.
474 Args:
475 document: the Word document to change.
476 font: the name of the font.
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)
491def _set_font(style: ParagraphStyle, font: str) -> None:
492 """Sets the font of a Word style, replacing any theme fonts.
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.
497 Args:
498 style: the Word style to change.
499 font: the name of the font.
501 """
502 style.font.name = font
503 fonts = style.element.rPr.find(qn('w:rFonts'))
504 _replace_theme_fonts(fonts, font)
507def _replace_theme_fonts(fonts: BaseOxmlElement, font: str) -> None:
508 """Replaces the theme fonts in a 'w:rFonts' element with `font`.
510 Args:
511 fonts: the 'w:rFonts' element.
512 font: the name of the font.
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)