Compare commits
10 Commits
1e4e3b7888
...
ae5159ef6a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae5159ef6a | ||
|
|
1aa097ffb5 | ||
|
|
e27d63b395 | ||
|
|
be1a5f6828 | ||
|
|
c7e0747f9a | ||
|
|
a95b412a45 | ||
|
|
ed1de68440 | ||
|
|
f9baaac388 | ||
|
|
e9c5215fb3 | ||
|
|
5b9e82e456 |
469
build.py
469
build.py
@ -1,105 +1,414 @@
|
||||
#!/usr/bin/python
|
||||
#!/usr/bin/env python
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
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
|
||||
base_dir = os.getcwd()
|
||||
content_dir = f"{base_dir}/content"
|
||||
config_dir = f"{base_dir}/config"
|
||||
output_dir = f"{base_dir}/output"
|
||||
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}")
|
||||
|
||||
def run_command(command):
|
||||
# run shell command
|
||||
stdout = subprocess.check_output(command)
|
||||
print(stdout)
|
||||
|
||||
def clean(args):
|
||||
pass
|
||||
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 epub(args):
|
||||
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
|
||||
pandoc_defaults_file = f"{config_dir}/pandoc-defaults.epub.yaml"
|
||||
defaults_file = str(config_dir / "pandoc-defaults.epub.yaml")
|
||||
|
||||
# define executable and options
|
||||
executable = "/usr/bin/pandoc"
|
||||
executable_opts = ["-d", pandoc_defaults_file]
|
||||
executable_opts.append("--verbose")
|
||||
executable, executable_opts = pandoc, pandoc_opts
|
||||
executable_opts.append("--defaults")
|
||||
executable_opts.append(defaults_file)
|
||||
|
||||
# take specific content file as input
|
||||
input_paths = [Path(content_dir) / "01 - frontmatter" / "30 - widmung.md", Path(content_dir) / "01 - frontmatter" / "50 - vorwort.md"]
|
||||
input_paths += sorted(Path(content_dir).glob('02 - mainmatter/*.md'))
|
||||
input_paths += sorted(Path(content_dir).glob('03 - backmatter/*.md'))
|
||||
input_files = [str(p) for p in input_paths]
|
||||
# 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
|
||||
command = [executable] + executable_opts + args + input_files
|
||||
print(f"command is {command}")
|
||||
run_command(command)
|
||||
_run_command(executable, ex_opts=executable_opts, infiles=input_files)
|
||||
|
||||
# validate epub
|
||||
if validate and which("epubcheck"):
|
||||
_run_command(which("epubcheck"), infiles=[outfile])
|
||||
|
||||
|
||||
def cover(args):
|
||||
# define executable and options
|
||||
executable = "/usr/bin/pdflatex"
|
||||
executable_opts = [f"-output-directory={output_dir}"]
|
||||
|
||||
# get input files from content dir
|
||||
cover_dir = f"{base_dir}/cover"
|
||||
input_files = [f"{cover_dir}/cover.tex"]
|
||||
|
||||
# put together command and run it
|
||||
command = [executable] + executable_opts + args + input_files
|
||||
print(f"command is {command}")
|
||||
run_command(command)
|
||||
|
||||
|
||||
|
||||
def pdf(args):
|
||||
# define pandoc defaults file
|
||||
pandoc_defaults_file = f"{config_dir}/pandoc-defaults.pdf.yaml"
|
||||
@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 = "/usr/bin/pandoc"
|
||||
executable_opts = ["-d", pandoc_defaults_file]
|
||||
#executable_opts.append("--verbose")
|
||||
executable, executable_opts = typst, typst_opts
|
||||
|
||||
# get input files from content dir
|
||||
input_paths = sorted(Path(content_dir).glob('**/*.md'))
|
||||
# input_paths = sorted(Path(content_dir).glob('03 - backmatter/*.md')) #DEBUG
|
||||
input_files = [str(p) for p in input_paths]
|
||||
# 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
|
||||
|
||||
# put together command and run it
|
||||
command = [executable] + executable_opts + args + input_files
|
||||
print(f"command is {command}")
|
||||
run_command(command)
|
||||
# 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__":
|
||||
# get command line parameters
|
||||
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)
|
||||
|
||||
|
||||
app()
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user