#!/usr/bin/env python from pathlib import Path import subprocess from enum import Enum from shutil import which from typing import Annotated import logging import typer import yaml from pypdf import PdfReader, PdfWriter # start logger logger = logging.getLogger(__name__) # define directories base_dir = Path.cwd() config_dir = base_dir / "config" content_dir = base_dir / "content" cover_dir = base_dir / "cover" output_dir = base_dir / "output" minizine_dir = base_dir / "promo" / "minizine" # define executables and default options pandoc = which("pandoc") if not pandoc: logger.critical("pandoc is not installed.") exit(1) pandoc_opts = [] typst = which("typst") if not typst: logger.critical("typst is not installed.") exit(1) typst_opts = ["compile", "--root", "."] # helper class for available output formats class Format(str, Enum): PDF = "pdf" PNG = "png" # instantiate typer app app = typer.Typer( no_args_is_help=True, help="Build script to make a professionally typeset book from markdown source files that can either be read on screen (PDF or EPUB) or be used in a project with Tredition.", context_settings={"help_option_names": ["-h", "--help"]} ) # ################## # INTERNAL FUNCTIONS # ################## def _get_input_files(target_format: str) -> list: # read filenames from file if such a file exists list_file = config_dir / f"source-files.{target_format}.txt" # read file if it exists if list_file.is_file(): with open(list_file, "r") as file: return [line.strip() for line in file if line[0] != "#"] # use all files otherwise; ignore tredition flag else: paths = content_dir.glob('**/*.md') return list(map(str, sorted(paths))) def _run_command(executable: str, infiles: list, ex_opts: list = [], outfile: str = ""): # put command together outfile = [outfile] if outfile else [] command = [executable] + ex_opts + infiles + outfile logger.debug(f"Executing command {command}") # run shell command try: stdout = subprocess.check_output(command, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: logger.critical(f"Command failed with return code {e.returncode}.") logger.critical(f"Command output: {e.output}") raise typer.Exit(code=e.returncode) else: logger.debug(f"Command finished with return code 0.") logger.debug(f"Command output: {stdout}") def _clean_dir(path: Path): for file in path.iterdir(): # if file: delete if file.is_file(): file.unlink() # if dir: delete recursively else: # clean dir recursively _clean_dir(file) # delete dir unless it's the output_dir if file != output_dir: file.rmdir() def _add_presuffix(path: Path|str, presuffix: str) -> Path: # cast past to Path if isinstance(path, str): path = Path(path) # add leading dot if nexessary if presuffix[0] != ".": presuffix = "." + presuffix return path.parent / f"{path.stem}{presuffix}{path.suffix}" def _merge_pdf_with_covers(frontcover: str, content: str, backcover: str, keep_coverless: bool=False): pdflist = [] # check if files exist for filename in [frontcover, content, backcover]: if Path(filename).is_file(): pdflist.append(filename) # merge files wwith pypdf merger = PdfWriter() for pdf in pdflist: merger.append(pdf) # write merged file outfile = str(_add_presuffix(content, ".with-covers")) if keep_coverless else content merger.write(outfile) merger.close() def _get_data_from_yaml(inputfile: str, key: str) -> str|list|dict: with open(inputfile, "r") as file: data = yaml.load(file, Loader=yaml.Loader) return data.get(key) # ############# # CLI COMMANDS # ############# @app.command() def pdf( tredition: Annotated[ bool, typer.Option("--tredition", "-t", help="Build PDFs in accordance with Tredition's requirements for print projects. Will produce a content file without covers and the book's envelope including the spine.") ] = False, keep_coverless: Annotated[ bool, typer.Option("--keep-coverless", "-k", help="Do not delete the content-only PDF file after merging it with the covers (does not apply if 'tredition' is set).") ] = False ): """ Build the printed book's PDF version (usually a single file containing front cover, content, and back cover). """ logger.info("Building PDF.") # define executable executable, executable_opts = pandoc, pandoc_opts # add pandoc defaults file to options defaults_file = str(config_dir / f"pandoc-defaults.{Format.PDF}.yaml") if Path(defaults_file).is_file(): executable_opts.append("--defaults") executable_opts.append(defaults_file) else: logger.critical(f"Pandoc defaults file {defaults_file} not found.") raise typer.Exit(code=1) # get input files input_files = _get_input_files(Format.PDF.value) # if tredition flag is set: remove empty first and/or last page if not tredition: for i in (0, -1): if input_files[i].endswith("leere seite.md"): del input_files[i] # get output filename (to be used later) outfile = _get_data_from_yaml(defaults_file, "output-file") if not outfile: logger.error("Could not get output filename from defaults file.") typer.Exit(2) # add presuffix ".tredition" to output filename if flag is set if tredition: outfile = str(_add_presuffix(outfile, ".tredition")) executable_opts.append("--output") executable_opts.append(outfile) # make PDf _run_command(executable, ex_opts=executable_opts, infiles=input_files) # make PDF covers cover(tredition=tredition, format=Format.PDF, frontcover=not tredition, backcover=not tredition, envelope=tredition) # add front-/backcover to PDF unless tredition flag is set if not tredition: _merge_pdf_with_covers(str(output_dir / "frontcover.pdf"), outfile, str(output_dir / "backcover.pdf"), keep_coverless) # check metadata -> try veraPDF @app.command() def epub( tredition: Annotated[ bool, typer.Option("--tredition", "-t", help="Build files in accordance with Tredition's requirements for ebook projects. Will produce a main EPUB file without covers and a separate PDF with the cover page.") ] = False, validate: Annotated[ bool, typer.Option("--validate", "-V", help="Validate completed EPUB file (requires 'epubcheck' executable in path).") ] = False ): """ Build the book's EPUB version (usually a single EPUB file with front cover). """ logger.info("Building EPUB.") # define pandoc defaults file defaults_file = str(config_dir / "pandoc-defaults.epub.yaml") # define executable and options executable, executable_opts = pandoc, pandoc_opts executable_opts.append("--defaults") executable_opts.append(defaults_file) # make frontcover format = Format.PDF if tredition else Format.PNG cover(format, tredition, backcover=False, envelope=False) # add cover to pandoc options (no-tredition only) if not tredition: executable_opts.append("--epub-cover-image") executable_opts.append(str(output_dir / "frontcover.png")) # get input files input_files = _get_input_files("epub") # get output filename outfile = _get_data_from_yaml(defaults_file, "output-file") # modify output filename (tredition only) if tredition: outfile = str(_add_presuffix(outfile, ".tredition")) executable_opts.append("--output") executable_opts.append(outfile) # put together command and run it _run_command(executable, ex_opts=executable_opts, infiles=input_files) # validate epub if validate and which("epubcheck"): _run_command(which("epubcheck"), infiles=[outfile]) @app.command() def cover( format: Annotated[ Format, typer.Option("--format", "-f", help="Set output format.") ] = Format.PDF, tredition: Annotated[ bool, typer.Option("--tredition", "-t", help="Build covers in accordance with Tredition's requirements. Produces a PDF with the ebook cover and a PDF with the printed book's envelope.") ] = False, frontcover: Annotated[ bool, typer.Option("--frontcover", "-c", help="Include the front cover when building.") ] = True, backcover: Annotated[ bool, typer.Option("--backcover", "-b", help="Include the back cover when building.") ] = False, envelope: Annotated[ bool, typer.Option("--envelope", "-e", help="Include the printed book's envelope when building.") ] = False, all_covers: Annotated[ bool, typer.Option("--all", "-a", help="Build all covers in all (sensible) formats.") ] = False ): """ Build only the cover(s). """ logger.info("Building cover(s).") if all_covers: envelope, frontcover, backcover = True, True, True tredition = True format = Format.PDF # define executable and options executable, executable_opts = typst, typst_opts # check for nonsensical flag combination if tredition and format == Format.PNG: logger.warning("PNG can't be used as output format in combination with --tredition; setting format to PDF.") format = Format.PDF # select files if tredition: paths = cover_dir.glob("*tredition*.typ") else: paths = [path for path in cover_dir.glob("*.typ") if not "tredition" in path.name] # remove input files with unset flags if not envelope: paths = [path for path in paths if not "envelope" in path.name] if not frontcover: paths = [path for path in paths if not "frontcover" in path.name] if not backcover: paths = [path for path in paths if not "backcover" in path.name] # iterate over files for path in paths: # get input file name input_files = [str(path)] # set output file name output_file = str(output_dir / f"{path.stem}.{format.value}") # run command _run_command(executable, infiles=input_files, outfile=output_file, ex_opts=executable_opts) # call function again if flag "all" is set (but don't recurse) if all_covers: cover(Format.PDF, tredition=False, frontcover=True, backcover=True, envelope=False, all_covers=False) cover(Format.PNG, tredition=False, frontcover=True, backcover=True, envelope=False, all_covers=False) @app.command() def minizine( format: Annotated[ Format, typer.Option("--format", "-f", help="Set output format.") ] = Format.PDF ): """ Build the minizine for the book. """ logger.debug("Building Minizine.") # define executable and options executable, executable_opts = typst, typst_opts # iterate over input files for path in minizine_dir.glob("*.typ"): # get input file name input_files = [str(path)] # set output file name output_file = str(output_dir / f"{path.stem}.{format.value}") # run command _run_command(executable, infiles=input_files, outfile=output_file, ex_opts=executable_opts) @app.command("build-all") def build_all(): """ Build every available target, with and without Tredition specs (where applicable). """ logger.debug("Building everything.") clean() minizine(format=Format.PDF) minizine(format=Format.PNG) cover(all_covers=True) epub() epub(tredition=True) pdf() pdf(tredition=True) @app.command() def clean(): """ Remove all files from output directory. """ logger.info("Cleaning output directory.") _clean_dir(output_dir) @app.callback() def main( log_level: Annotated[ str, typer.Option("--log-level", "-l", help="Set log level.") ] = "INFO", outdir: Annotated[ str, typer.Option("--output-dir", "-o", help="Specify output directory.") ] = "output/" ): # set user log level if not getattr(logging, log_level.upper()): log_level = "INFO" # configure logger logging.basicConfig( level=getattr(logging, log_level.upper()), format="{asctime} {levelname}: {message}", style="{", datefmt="%Y-%m-%d %H:%M:%S" ) logger.debug("Logger configured.") # set output directory p = Path(outdir).expanduser() globals()["output_dir"] = p if p.is_absolute() else base_dir / p od = globals()["output_dir"] logger.debug(f"Output directory is {od}") od.mkdir(parents=True, exist_ok=True) if __name__ == "__main__": app()