Compare commits

...

10 Commits

467
build.py
View File

@ -1,105 +1,414 @@
#!/usr/bin/python #!/usr/bin/env python
import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import sys 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 # define directories
base_dir = os.getcwd() base_dir = Path.cwd()
content_dir = f"{base_dir}/content" config_dir = base_dir / "config"
config_dir = f"{base_dir}/config" content_dir = base_dir / "content"
output_dir = f"{base_dir}/output" 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}")
def run_command(command):
# run shell command # run shell command
stdout = subprocess.check_output(command) try:
print(stdout) stdout = subprocess.check_output(command, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
def clean(args): logger.critical(f"Command failed with return code {e.returncode}.")
pass 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 epub(args): def _clean_dir(path: Path):
# define pandoc defaults file for file in path.iterdir():
pandoc_defaults_file = f"{config_dir}/pandoc-defaults.epub.yaml" # if file: delete
if file.is_file():
# define executable and options file.unlink()
executable = "/usr/bin/pandoc" # if dir: delete recursively
executable_opts = ["-d", pandoc_defaults_file] else:
executable_opts.append("--verbose") # clean dir recursively
_clean_dir(file)
# take specific content file as input # delete dir unless it's the output_dir
input_paths = [Path(content_dir) / "01 - frontmatter" / "30 - widmung.md", Path(content_dir) / "01 - frontmatter" / "50 - vorwort.md"] if file != output_dir:
input_paths += sorted(Path(content_dir).glob('02 - mainmatter/*.md')) file.rmdir()
input_paths += sorted(Path(content_dir).glob('03 - backmatter/*.md'))
input_files = [str(p) for p in input_paths]
# put together command and run it
command = [executable] + executable_opts + args + input_files
print(f"command is {command}")
run_command(command)
def cover(args): def _add_presuffix(path: Path|str, presuffix: str) -> Path:
# define executable and options # cast past to Path
executable = "/usr/bin/pdflatex" if isinstance(path, str):
executable_opts = [f"-output-directory={output_dir}"] 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)
# get input files from content dir # merge files wwith pypdf
cover_dir = f"{base_dir}/cover" merger = PdfWriter()
input_files = [f"{cover_dir}/cover.tex"] for pdf in pdflist:
merger.append(pdf)
# put together command and run it # write merged file
command = [executable] + executable_opts + args + input_files outfile = str(_add_presuffix(content, ".with-covers")) if keep_coverless else content
print(f"command is {command}") merger.write(outfile)
run_command(command) 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)
def pdf(args): # #############
# 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 # define pandoc defaults file
pandoc_defaults_file = f"{config_dir}/pandoc-defaults.pdf.yaml" defaults_file = str(config_dir / "pandoc-defaults.epub.yaml")
# define executable and options # define executable and options
executable = "/usr/bin/pandoc" executable, executable_opts = pandoc, pandoc_opts
executable_opts = ["-d", pandoc_defaults_file] executable_opts.append("--defaults")
#executable_opts.append("--verbose") executable_opts.append(defaults_file)
# get input files from content dir # make frontcover
input_paths = sorted(Path(content_dir).glob('**/*.md')) format = Format.PDF if tredition else Format.PNG
# input_paths = sorted(Path(content_dir).glob('03 - backmatter/*.md')) #DEBUG cover(format, tredition, backcover=False, envelope=False)
input_files = [str(p) for p in input_paths]
# 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 # put together command and run it
command = [executable] + executable_opts + args + input_files _run_command(executable, ex_opts=executable_opts, infiles=input_files)
print(f"command is {command}")
run_command(command) # 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__": if __name__ == "__main__":
# get command line parameters app()
args = sys.argv[1:]
if len(args) == 0:
print("Please specify at least one parameter")
exit(1)
# call function corresponding to first argument
if args[0] == "clean":
clean(args[1:])
elif args[0] == "pdf":
pdf(args[1:])
elif args[0] == "cover":
cover(args[1:])
elif args[0] == "epub":
epub(args[1:])
elif args[0] == "all":
pdf(args[1:])
cover(args[1:])
epub(args[1:])
else:
print("Parameter not recognized")
exit(2)