# Author: Cameron F. Abrams, <cfa22@drexel.edu>
"""
Document build functions for pygacity
"""
import logging
import re
from copy import deepcopy
from importlib.resources import files
from pathlib import Path
from .block import LatexBlock
logger = logging.getLogger(__name__)
[docs]
class Document:
"""
Represents a LaTeX document composed of multiple blocks.
Attributes
----------
specs : dict
document specifications
blocks : list of LatexCompoundBlock
list of blocks in the document
preamble : list of str
list of LaTeX preamble lines
substitutions : dict
dictionary of substitutions to apply in the document
has_pycode : bool
indicates if the document contains embedded Python code
embedded_graphics : list of str
list of embedded graphics file paths
"""
resources_root: Path = files('pygacity') / 'resources'
""" Root directory for resource files. """
templates_dir: Path = resources_root / 'templates'
""" Directory for template files. """
def __init__(self, document_specs: dict):
"""
Initializes the Document instance.
Parameters
----------
document_specs : dict
document specifications
"""
self.blocks: list[LatexBlock] = []
self.specs = deepcopy(document_specs)
preamble_text = self._resolve_preamble(self.specs.get('preamble', None))
self.preamble = LatexBlock(block_specs={'text': preamble_text}).load()
self.substitutions = self.specs.get('substitutions', {})
logger.debug(f'Document.__init__ with specs: {self.specs}')
for idx, block_specs in enumerate(self.specs['structure']):
assert type(block_specs) == dict
self.blocks.extend(LatexBlock.from_specs(block_specs))
logger.debug(f'Added block {idx}: {block_specs}')
self.has_pycode = any(block.has_pycode for block in self.blocks)
self.embedded_graphics = []
for block in self.blocks:
self.embedded_graphics.extend(block.embedded_graphics)
def _get_autoprob_commands(self) -> set:
"""Return the set of zero-argument ``\\newcommand`` names defined in autoprob.cls."""
cls_path = self.resources_root / 'autoprob-package' / 'tex' / 'latex' / 'autoprob.cls'
text = cls_path.read_text(encoding='utf-8')
# match \newcommand{\Name}{...} but NOT \newcommand{\Name}[n] (which have arguments)
return {
m.group(1)
for m in re.finditer(r'\\newcommand\{\\([A-Za-z]+)\}(?!\[)', text)
}
def _resolve_preamble_attr(self, attr: str, val) -> str:
"""Resolve one preamble sub-key (*font*, *pagestyle*, *colors*, or *commands*) to LaTeX text."""
preambles_dir = self.resources_root / 'preambles'
KEYWORDS = {'default', 'example', 'single'}
if isinstance(val, str):
if val in KEYWORDS:
fname = f'{val}_{attr}.tex'
fpath = preambles_dir / fname
if not fpath.is_file():
raise FileNotFoundError(
f'Preamble resource file not found: {fname}. '
f'Expected it at {fpath}'
)
return fpath.read_text(encoding='utf-8')
else:
# literal LaTeX string
return val
elif isinstance(val, dict):
if attr in ('font', 'pagestyle', 'colors'):
if 'input' not in val:
raise ValueError(
f'preamble.{attr} dict must contain an "input" key '
f'naming a local file to read'
)
local_file = Path(val['input'])
if not local_file.is_file():
raise FileNotFoundError(
f'Local preamble file not found: {local_file}'
)
return local_file.read_text(encoding='utf-8')
elif attr == 'commands':
valid_cmds = self._get_autoprob_commands()
lines = []
for cmd, value in val.items():
if cmd not in valid_cmds:
raise ValueError(
f'Unknown command "{cmd}" in preamble.commands. '
f'Valid commands: {sorted(valid_cmds)}'
)
lines.append(rf'\renewcommand{{\{cmd}}}{{{value}}}')
return '\n'.join(lines)
else:
raise ValueError(
f'preamble.{attr}: dict value is not supported for this attribute. '
f'Use a keyword string ("default"/"example"), a literal LaTeX string, '
f'or — for font/pagestyle/colors — a dict with an "input" key.'
)
else:
raise ValueError(
f'preamble.{attr} must be a string or dict, got {type(val).__name__}'
)
def _resolve_preamble(self, preamble_specs) -> str:
"""
Resolve ``document.preamble`` specs to a LaTeX string.
Accepts three forms:
* **None** (or absent from config) — defaults are applied: *font* and
*pagestyle* both fall back to ``"default"``.
* **str** — used as-is (backward-compatible literal LaTeX); no
defaults are injected.
* **dict** — structured form with optional sub-keys *font*,
*pagestyle*, *colors*, and *commands*, each resolved independently
and concatenated in that order. *font* and *pagestyle* default to
``"default"`` when absent; set either to ``null`` (``~``) in YAML
to suppress its default entirely. *colors* defaults to ``None``
(no color setup) when absent.
"""
if isinstance(preamble_specs, str):
return preamble_specs
if preamble_specs is None:
preamble_specs = {}
if isinstance(preamble_specs, dict):
parts = []
for attr in ('font', 'pagestyle', 'colors', 'commands'):
val = preamble_specs.get(attr, 'default' if attr in ('font', 'pagestyle') else None)
if val is None:
continue
parts.append(self._resolve_preamble_attr(attr, val))
# any remaining unknown keys are treated as literal LaTeX strings
for attr, val in preamble_specs.items():
if attr not in ('font', 'pagestyle', 'colors', 'commands'):
logger.warning(f'Unknown preamble attribute "{attr}"; treating value as literal LaTeX')
parts.append(str(val))
return '\n'.join(parts)
raise ValueError(
f'document.preamble must be a string or dict, got {type(preamble_specs).__name__}'
)
[docs]
def make_substitutions(self, outer_substitutions: dict = {}):
"""
Applies substitutions to all blocks in the document.
Parameters
----------
outer_substitutions : dict, optional
additional substitutions to apply (default is empty dict)
"""
self.substitutions.update(deepcopy(outer_substitutions))
logger.debug(f'Document.make_substitutions with substitutions: {self.substitutions}')
self.preamble.substitute(super_substitutions=self.substitutions)
for block in self.blocks:
block.substitute(super_substitutions=self.substitutions)
[docs]
def write_source(self, local_output_name: str = 'local_document'):
"""
Writes the LaTeX source of the document to a .tex file.
Parameters
----------
local_output_name : str, optional
base name for the output .tex file (default is 'local_document')
"""
with open(local_output_name + '.tex', 'w', encoding='utf-8') as f:
f.write('% Automatically generated LaTeX source file\n')
class_specs = self.specs.get('class', {})
logger.debug(f'Document.write_source with class_specs: {class_specs}')
dcoptions = class_specs.get('options', [])
classname = class_specs.get('classname', 'article')
f.write(rf'\documentclass[{", ".join(dcoptions)}]{{{classname}}}' + '\n')
f.write(str(self.preamble) + '\n')
f.write(r'\begin{document}' + '\n')
enumerating = False
for block in self.blocks:
if block.question_number is not None and not enumerating:
f.write(r'\begin{enumerate}' + '\n')
enumerating = True
if block.question_number is None and enumerating:
f.write(r'\end{enumerate}' + '\n')
enumerating = False
if enumerating:
f.write(rf'\item[{block.question_number}.] ' + str(block) + '\n')
else:
f.write(str(block) + '\n')
if enumerating:
f.write(r'\end{enumerate}' + '\n')
enumerating = False
f.write(r'\end{document}' + '\n')
f.write('% End of automatically generated LaTeX source file\n')