#!/usr/bin/env python from pathlib import Path import subprocess from enum import Enum from shutil import which from typing import Annotated import typer import yaml from pypdf import PdfReader, PdfWriter # 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: print("Critical: pandoc is not installed; aborting.") exit(1) pandoc_opts = [] typst = which("typst") if not typst: print("Critical: pandoc is not installed; aborting.") exit(1) typst_opts = ["compile", "--font-path", "static/font", "--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 (PDF or EPUB) from markdown source files." ) # ################## # INTERNAL FUNCTIONS # ################## def _get_input_files(format: Format | str) -> list: if type(format) == Format: format = format.value # read filenames from file if such a file exists list_file = config_dir / f"source-files.{format}.txt" if list_file.is_file(): with open(list_file, "r") as file: return [line.strip() for line in file] # 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 print(f"command is {command}") # run shell command try: stdout = subprocess.check_output(command, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: print(f"Ciritical: Command failed with return code {e.returncode}.") print(e.output) raise typer.Exit(code=e.returncode) else: print(f"Success: Command finished with return code 0.") print(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 type(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 clean(): """ Remove all files from output directory. """ _clean_dir(output_dir) # format: pdf, png # tredition: n.a. @app.command() def minizine( format: Annotated[ Format, typer.Option("--format", "-f", help="Set output format.") ] = Format.pdf ): """ Build the minizine for the book. """ # 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() 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). """ # 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") # use modified output filename (tredition only) if tredition: outfile = _get_data_from_yaml(defaults_file, "output-file") 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=[str(output_dir / "papa-lach-doch-mal.epub")]) @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). """ # 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: print(f"Critical: Pandoc defaults file {defaults_file} not found.") raise typer.Exit(code=1) # get input files input_files = _get_input_files(Format.pdf) # 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") # 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 pass @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, no_frontcover: Annotated[ bool, typer.Option("--no_frontcover", "-nf", help="Include the front cover when building.") ] = False, no_backcover: Annotated[ bool, typer.Option("--no-backcover", "-nb", help="Include the back cover when building.") ] = False, no_envelope: Annotated[ bool, typer.Option("--no-envelope", "-ne", help="Include the printed book's envelope when building.") ] = False ): """ Build the cover(s) only. """ # define executable and options executable, executable_opts = typst, typst_opts # check for nonsensical flag combination if tredition and format == Format.png: print("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 no_envelope: paths = [path for path in paths if not "envelope" in path.name] if no_frontcover: paths = [path for path in paths if not "frontcover" in path.name] if no_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) if __name__ == "__main__": app()