from pathlib import Path import os import re import subprocess import logging from docutils import core from docutils.writers.latex2e import Writer, Table, LaTeXTranslator from sphinx.util.docstrings import prepare_docstring from dungeonsheets import exceptions from dungeonsheets.forms import dice_re log = logging.getLogger(__name__) class LatexWriter(Writer): def __init__(self): super().__init__() self.translator_class = DNDTranslator class DNDTable(Table): pass class DNDTranslator(LaTeXTranslator): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.settings.table_style = ["borderless"] self.active_table = DNDTable(self, "supertabular") def _remove_temp_files(basename_): # Convenience function for removing temporary files filenames = [ Path(f"{basename_}.tex"), Path(f"{basename_}.aux"), Path(f"{basename_}.log"), ] for filename in filenames: if filename.exists(): filename.unlink() def create_latex_pdf( tex: str, basename: str, keep_temp_files: bool = False, use_dnd_decorations: bool = False, comm1: str = "pdflatex" ): # Create tex document tex_file = f"{basename}.tex" with open(tex_file, mode="w", encoding="utf-8") as f: f.write(tex) # Compile the PDF pdf_file = Path(f"{basename}.pdf") output_dir = pdf_file.resolve().parent tex_command_line = [ comm1, "--output-directory", str(output_dir), "-halt-on-error", "-interaction=nonstopmode", str(tex_file), ] environment = os.environ tex_env = environment.get('TEXINPUTS', '') module_root = Path(__file__).parent / "modules/" module_dirs = [module_root / mdir for mdir in ["DND-5e-LaTeX-Template"]] log.debug(f"Loading additional modules from {module_dirs}.") texinputs = f".:{':'.join(str(d) for d in module_dirs)}:{module_root}:{tex_env}" environment['TEXINPUTS'] = texinputs passes = 2 if use_dnd_decorations else 1 log.debug(tex_command_line) log.debug("LaTeX command: %s" % " ".join(tex_command_line)) log.debug("LaTeX TEXINPUTS: %s" % texinputs) log.debug("LaTeX environ:") for key, val in environment.items(): log.debug(" %s: %s" % (key, val)) try: for i in range(passes): result = subprocess.run( tex_command_line, stdout=subprocess.DEVNULL, env=environment, timeout=30 ) except FileNotFoundError: # Remove temporary files _remove_temp_files(basename) raise exceptions.LatexNotFoundError() else: if result.returncode == 0 and not keep_temp_files: _remove_temp_files(basename) if result.returncode != 0: # Prepare to raise an exception logfile = Path(f"{basename}.log") err_msg = f"Processing of {basename}.tex failed. See {logfile} for details." # Load the log file for more details tex_error_msg = tex_error(logfile) if tex_error_msg: for line in tex_error_msg.split("\n"): log.error(line) raise exceptions.LatexError(err_msg) finally: environment['TEXINPUTS'] = tex_env def tex_error(logfile: Path) -> str: """Parse a LaTeX log file and look for errors.""" has_error = False error_lines = [] if logfile.exists(): with open(logfile, mode="r") as fp: for line in fp.readlines(): # Check for the start of an error message if "LaTeX Error" in line: has_error = True # We've already found an error, so save this line for later if has_error: error_lines.append(line) return "".join(error_lines) def latex_parts( input_string, source_path=None, destination_path=None, input_encoding="unicode", doctitle=True, initial_header_level=1, ): """ Given an input string, returns a dictionary of HTML document parts. Dictionary keys are the names of parts, and values are Unicode strings; encoding is up to the client. Parameters: - `input_string`: A multi-line text string; required. - `source_path`: Path to the source file or object. Optional, but useful for diagnostic output (system messages). - `destination_path`: Path to the file or object which will receive the output; optional. Used for determining relative paths (stylesheets, source links, etc.). - `input_encoding`: The encoding of `input_string`. If it is an encoded 8-bit string, provide the correct encoding. If it is a Unicode string, use "unicode", the default. - `doctitle`: Disable the promotion of a lone top-level section title to document title (and subsequent section title to document subtitle promotion); enabled by default. - `initial_header_level`: The initial level for header elements (e.g. 1 for "

"). """ # Remove indentation, etc input_string = "\n".join(prepare_docstring(input_string)) # Parse from rst to TeX overrides = { "input_encoding": input_encoding, "doctitle_xform": doctitle, "initial_header_level": initial_header_level, } writer = LatexWriter() parts = core.publish_parts( source=input_string, source_path=source_path, destination_path=destination_path, writer=writer, settings_overrides=overrides, ) return parts def rst_to_latex(rst, top_heading_level: int=0, format_dice: bool = True, use_dnd_decorations=False): """Basic markup of reST to LaTeX code. The translation between reST headings and LaTeX headings is modified by the *top_heading_level* parameter. A value of 0 (default) translates "# Heading" -> "\\section{Heading}". A value of 1 translates "# Heading" -> "\\subsection{Heading}", etc. Note: heading translation is currently broken. Parameters ========== rst reStructured text input to be parsed. top_heading_level : optional The highest level heading that will be added to the LaTeX as described above. format_dice If true, dice strings (e.g. "1d4") will be formatted in monospace font. Returns ======= tex : str The reST text parsed into LaTeX markup. """ if rst is None: # No reST, so return an empty string tex = "" else: # Mark hit dice in monospace font if format_dice: rst = dice_re.sub(r"``\1``", rst) tex_parts = latex_parts(rst) tex = tex_parts["body"] # Apply fancy D&D decorations if use_dnd_decorations: tex = re.sub(r"p{[0-9.]+\\DUtablewidth}", "l ", tex, flags=re.M) tex = tex.replace(r"\begin{supertabular}[c]", r"\begin{DndTable}") tex = tex.replace(r"\begin{supertabular}", r"\begin{DndTable}") tex = tex.replace(r"\end{supertabular}", r"\end{DndTable}") return tex def rst_to_boxlatex(rst): """Adapted rst translation from dungeonsheets latex module, removing dice parsing and indentation.""" if rst is None: return "" tex_parts = latex_parts(rst) tex = tex_parts["body"] tex = tex.replace('\n\n', ' \\\\\n') return tex def msavage_spell_info(char): """Generates the spellsheet for msavage template.""" headinfo = char.spell_casting_info["head"] font_options = {1:"", 2:r"\Large ", 3:r"\large "} selector = min(len(char.spellcasting_classes), 3) fs_command = font_options[selector] sc_classes = r"\SpellcastingClass{" \ + fs_command \ + headinfo["classes_and_levels"].replace(" / ", ", ") \ + "}" sc_abilities = r"\SpellcastingAbility{" \ + fs_command \ + headinfo["abilities"].replace(" ", "") \ + "}" sc_savedc = r"\SpellSaveDC{" \ + fs_command \ + headinfo["DCs"].replace(" ", "") \ + "}" sc_atk = r"\SpellAttackBonus{" \ + fs_command \ + headinfo["bonuses"].replace(" ", "") \ + "}" tex1 = "\n".join([sc_classes, sc_abilities, sc_savedc, sc_atk]) + "\n" spellslots = char.spell_casting_info["slots"] texT = [] for k, v in spellslots.items(): texT.append("\\" + k + "SlotsTotal{" + str(v) + "}") tex2 = "\n".join(texT) + "\n" texT = [] level_names = level_names = ["Cantrip", 'FirstLevelSpell', 'SecondLevelSpell', 'ThirdLevelSpell', 'FourthLevelSpell', 'FifthLevelSpell', 'SixthLevelSpell', 'SeventhLevelSpell', 'EighthLevelSpell', 'NinthLevelSpell'] sheet_spaces = dict(zip(level_names, [8, 13, 13, 13, 13, 9, 9, 9, 7, 7])) comp_letters = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M"] spellList = char.spell_casting_info["list"] for k, v in spellList.items(): slots_max = sheet_spaces[k] if len(v) > slots_max: vsel = sorted(v, key=lambda x: x[1], reverse=True) else: vsel = v[:] for spinfo, slot in zip(vsel[:slots_max], comp_letters): slot_command = "\\"+k+'Slot'+slot slot_command_name = slot_command+"{"+spinfo[0]+"}" if k == "Cantrip": texT = texT + [slot_command_name] continue slot_command_prep = slot_command+"Prepared"+"{"+str(spinfo[1])+"}" texT = texT + [slot_command_name, slot_command_prep] tex3 = "\n".join(texT) + '\n' return "\n".join([tex1, tex2, tex3])