bookbuild/build.py

353 lines
10 KiB
Python
Executable File

#!/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 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(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 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 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 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).
"""
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:
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 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.
"""
# 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 clean():
"""
Remove all files from output directory.
"""
_clean_dir(output_dir)
if __name__ == "__main__":
app()