Compare commits
No commits in common. "64cf32d43c911e63ed7f869c24ec84e40200d983" and "573aec8ad757f51ff370c90e39260d866ec9bf36" have entirely different histories.
64cf32d43c
...
573aec8ad7
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,4 +2,3 @@
|
|||||||
output/
|
output/
|
||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
dist/
|
|
||||||
|
|||||||
414
build.py
Executable file
414
build.py
Executable file
@ -0,0 +1,414 @@
|
|||||||
|
#!/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()
|
||||||
|
|
||||||
@ -1,10 +1,12 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "bookbuild"
|
name = "bookbuild"
|
||||||
version = "0.2.0"
|
version = "0.1.0"
|
||||||
description = "Build script for my book projects"
|
description = "Build script for my book projects"
|
||||||
authors = ["Tobias Radloff <mail@tobias-radloff.de>"]
|
authors = ["Tobias Radloff <mail@tobias-radloff.de>"]
|
||||||
license = "GPLv3"
|
license = "GPLv3"
|
||||||
packages = [{ include = "bookbuild", from = "src" }]
|
packages = [
|
||||||
|
{ include = "bookbuild", from = "src" }
|
||||||
|
]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.10"
|
python = "^3.10"
|
||||||
@ -20,3 +22,4 @@ bookbuild = 'bookbuild.main:app'
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
import typer
|
|
||||||
|
|
||||||
from .defaults import LOGGER, Format
|
|
||||||
from .clean import clean
|
|
||||||
from .minizine import minizine
|
|
||||||
from .cover import cover
|
|
||||||
from .epub import epub
|
|
||||||
from .pdf import pdf
|
|
||||||
|
|
||||||
# instantiate typer app
|
|
||||||
all_app = typer.Typer()
|
|
||||||
|
|
||||||
@all_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)
|
|
||||||
@ -35,7 +35,7 @@ def _clean_dir(path: Path) -> None:
|
|||||||
# ##############
|
# ##############
|
||||||
|
|
||||||
@clean_app.command()
|
@clean_app.command()
|
||||||
def clean():
|
def clean(ctx: typer.Context):
|
||||||
"""
|
"""
|
||||||
Remove all files from output directory.
|
Remove all files from output directory.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,92 +0,0 @@
|
|||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import typer
|
|
||||||
|
|
||||||
from .defaults import *
|
|
||||||
|
|
||||||
# instantiate typer app
|
|
||||||
cover_app = typer.Typer()
|
|
||||||
|
|
||||||
|
|
||||||
# ##############
|
|
||||||
# TYPER COMMANDS
|
|
||||||
# ##############
|
|
||||||
|
|
||||||
@cover_app.command()
|
|
||||||
def cover(
|
|
||||||
format: Annotated[
|
|
||||||
Format,
|
|
||||||
typer.Option("--format", "-F", help="Set output format.")
|
|
||||||
] = Format.PDF,
|
|
||||||
input_dir: Annotated[
|
|
||||||
str,
|
|
||||||
typer.Option("--input-dir", "-i", help=f"Specify input directory (either absolute or relative to {str(BASE_DIR)}).")
|
|
||||||
] = str(COVER_DIR),
|
|
||||||
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", "-f", 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).
|
|
||||||
"""
|
|
||||||
# all_covers requires a specific combination of parameters
|
|
||||||
if all_covers:
|
|
||||||
envelope, frontcover, backcover = True, True, True
|
|
||||||
tredition = True
|
|
||||||
format = Format.PDF
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
LOGGER.info(f"Building cover(s) as {format.upper()} file(s).")
|
|
||||||
|
|
||||||
# get typst executable
|
|
||||||
typst = PROGRAMS["typst"]
|
|
||||||
|
|
||||||
# select files
|
|
||||||
input_dir = Path(input_dir)
|
|
||||||
if tredition:
|
|
||||||
paths = input_dir.glob("*tredition*.typ")
|
|
||||||
else:
|
|
||||||
paths = [path for path in input_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(typst["executable"], ex_opts=typst["options"], infiles=input_files, outfile=output_file)
|
|
||||||
|
|
||||||
# call function again if flag "all" is set (but don't recurse)
|
|
||||||
if all_covers:
|
|
||||||
cover(Format.PDF, input_dir=str(input_dir), tredition=False, frontcover=True, backcover=True, envelope=False, all_covers=False)
|
|
||||||
cover(Format.PNG, input_dir=str(input_dir), tredition=False, frontcover=True, backcover=True, envelope=False, all_covers=False)
|
|
||||||
@ -6,11 +6,8 @@ from pathlib import Path
|
|||||||
from shutil import which
|
from shutil import which
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import yaml
|
|
||||||
from pypdf import PdfWriter
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
|
|
||||||
# start logger
|
# start logger
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -19,6 +16,7 @@ class Format(str, Enum):
|
|||||||
PDF = "pdf"
|
PDF = "pdf"
|
||||||
PNG = "png"
|
PNG = "png"
|
||||||
|
|
||||||
|
|
||||||
# directory defaults
|
# directory defaults
|
||||||
BASE_DIR = Path.cwd()
|
BASE_DIR = Path.cwd()
|
||||||
CONFIG_DIR = BASE_DIR / "config"
|
CONFIG_DIR = BASE_DIR / "config"
|
||||||
@ -31,93 +29,17 @@ OUTPUT_DIR = BASE_DIR / "output"
|
|||||||
LOG_LEVEL = "INFO"
|
LOG_LEVEL = "INFO"
|
||||||
|
|
||||||
|
|
||||||
# list of shell programs and options
|
# default executables and options
|
||||||
PROGRAMS = {
|
EXECUTABLES = {
|
||||||
"pandoc": {"executable": None, "options": []},
|
"pandoc": {"executable": "", "options": []},
|
||||||
"typst": {"executable": None, "options": ["compile", "--root", "."]},
|
"typst": {"executable": "", "options": ["compile", "--root", "."]}
|
||||||
}
|
}
|
||||||
OPTIONAL_PROGRAMS = {
|
|
||||||
"epubcheck": {"executable": None, "options": []}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ##################
|
# ##################
|
||||||
# INTERNAL FUNCTIONS
|
# INTERNAL FUNCTIONS
|
||||||
# ##################
|
# ##################
|
||||||
|
|
||||||
def absolutify(path: str) -> Path:
|
|
||||||
"""
|
|
||||||
Takes a path in string form and returns a Path object which is either the original path (if it was absolute) or relative to the current working directory.
|
|
||||||
"""
|
|
||||||
p = Path(path).expanduser()
|
|
||||||
return p if p.is_absolute() else BASE_DIR / p
|
|
||||||
|
|
||||||
|
|
||||||
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 get_data_from_yaml(input_file: str, key: str) -> str|list|dict:
|
|
||||||
with open(input_file, "r") as file:
|
|
||||||
data = yaml.load(file, Loader=yaml.Loader)
|
|
||||||
return data.get(key)
|
|
||||||
|
|
||||||
|
|
||||||
def __get_executable(cmd: str, optional: bool = False) -> str|None:
|
|
||||||
"""
|
|
||||||
Returns the path to a given shell command. If the command doesn't exist in path, the function exits unless optional is True, in which case the return value is None.
|
|
||||||
"""
|
|
||||||
exe = which(cmd)
|
|
||||||
if not exe:
|
|
||||||
if not optional:
|
|
||||||
LOGGER.critical(f"Required program {cmd} is not installed!")
|
|
||||||
raise typer.Exit()
|
|
||||||
else:
|
|
||||||
LOGGER.warning(f"Optional program {cmd} is not installed!")
|
|
||||||
return None
|
|
||||||
return exe
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
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 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 run_command(executable: str, infiles: list, ex_opts: list = [], outfile: str = ""):
|
def run_command(executable: str, infiles: list, ex_opts: list = [], outfile: str = ""):
|
||||||
"""
|
"""
|
||||||
Runs a given executable as a shell command using the given options, list of input filenames, and optional output filename.
|
Runs a given executable as a shell command using the given options, list of input filenames, and optional output filename.
|
||||||
@ -129,22 +51,44 @@ def run_command(executable: str, infiles: list, ex_opts: list = [], outfile: str
|
|||||||
|
|
||||||
# run shell command
|
# run shell command
|
||||||
try:
|
try:
|
||||||
stdout = subprocess.check_output(command, stderr=subprocess.STDOUT)
|
# stdout = subprocess.check_output(command, stderr=subprocess.STDOUT)
|
||||||
pass #DEBUG
|
pass #DEBUG
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
LOGGER.critical(f"run_command(): {executable} failed with return code {e.returncode} and this output:\n{e.output.decode('utf-8')}")
|
LOGGER.critical(f"Command failed with return code {e.returncode}.")
|
||||||
|
LOGGER.critical(f"Command output: {e.output}")
|
||||||
raise typer.Exit(code=e.returncode)
|
raise typer.Exit(code=e.returncode)
|
||||||
else:
|
else:
|
||||||
LOGGER.debug(f"run_command(): {executable} finished with return code 0 and this output:\n{stdout.decode('utf-8')}")
|
LOGGER.debug(f"Command finished with return code 0.")
|
||||||
|
# LOGGER.debug(f"Command output: {stdout}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_executable(cmd: str) -> str:
|
||||||
|
"""
|
||||||
|
Returns the path to shell command `cmd` or exits.
|
||||||
|
"""
|
||||||
|
exe = which(cmd)
|
||||||
|
if not exe:
|
||||||
|
LOGGER.critical(f"{cmd} is not installed!")
|
||||||
|
typer.Exit(1)
|
||||||
|
return exe
|
||||||
|
|
||||||
|
|
||||||
|
def absolutify(path: str) -> Path:
|
||||||
|
"""
|
||||||
|
Takes a path in string form and returns a Path object which is either the original path (if it was absolute) or relative to the current working directory.
|
||||||
|
"""
|
||||||
|
p = Path(path).expanduser()
|
||||||
|
return p if p.is_absolute() else BASE_DIR / p
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ##################
|
# ##############
|
||||||
# CALLBACK FUNCTIONS
|
# TYPER CALLBACK
|
||||||
# ##################
|
# ##############
|
||||||
|
|
||||||
# callback function for the main typer app
|
# callback function for the main typer app
|
||||||
def main_callback(
|
def callback(
|
||||||
|
ctx: typer.Context,
|
||||||
log_level: Annotated[
|
log_level: Annotated[
|
||||||
str,
|
str,
|
||||||
typer.Option("--log-level", "-l", help="Set log level.")
|
typer.Option("--log-level", "-l", help="Set log level.")
|
||||||
@ -176,26 +120,3 @@ def main_callback(
|
|||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
LOGGER.debug(f"output_dir is {OUTPUT_DIR}")
|
LOGGER.debug(f"output_dir is {OUTPUT_DIR}")
|
||||||
|
|
||||||
# set paths to executables
|
|
||||||
for k, v in PROGRAMS.items():
|
|
||||||
v["executable"] = __get_executable(k)
|
|
||||||
for k, v in OPTIONAL_PROGRAMS.items():
|
|
||||||
v["executable"] = __get_executable(k)
|
|
||||||
|
|
||||||
|
|
||||||
def input_dir_callback(input_dir: str) -> str:
|
|
||||||
"""
|
|
||||||
Validates the argument input_dir which is used by several commands.
|
|
||||||
"""
|
|
||||||
input_dir = absolutify(input_dir)
|
|
||||||
|
|
||||||
# make sure dir exists
|
|
||||||
if not input_dir.exists():
|
|
||||||
raise typer.BadParameter(f"Input directory {input_dir} does not exist.")
|
|
||||||
|
|
||||||
# make sure dir isn't empty
|
|
||||||
if not next(input_dir.glob("*"), None):
|
|
||||||
raise typer.BadParameter(f"Input directory {input_dir} is empty.")
|
|
||||||
|
|
||||||
# OK
|
|
||||||
return str(input_dir)
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import typer
|
|
||||||
|
|
||||||
from .defaults import *
|
|
||||||
from .cover import cover
|
|
||||||
|
|
||||||
# instantiate typer app
|
|
||||||
epub_app = typer.Typer()
|
|
||||||
|
|
||||||
|
|
||||||
# ##############
|
|
||||||
# TYPER COMMANDS
|
|
||||||
# ##############
|
|
||||||
|
|
||||||
@epub_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 = CONFIG_DIR / "pandoc-defaults.epub.yaml"
|
|
||||||
if not defaults_file.exists():
|
|
||||||
LOGGER.critical(f"Defaults file {defaults_file} not found.")
|
|
||||||
raise typer.Exit()
|
|
||||||
|
|
||||||
# define executable and options
|
|
||||||
pandoc = PROGRAMS["pandoc"]
|
|
||||||
pandoc["options"].append("--defaults")
|
|
||||||
pandoc["options"].append(str(defaults_file))
|
|
||||||
|
|
||||||
# make frontcover
|
|
||||||
format = Format.PDF if tredition else Format.PNG
|
|
||||||
cover(format, tredition=tredition, backcover=False, envelope=False)
|
|
||||||
|
|
||||||
# add cover to pandoc options (no-tredition only)
|
|
||||||
if not tredition:
|
|
||||||
pandoc["options"].append("--epub-cover-image")
|
|
||||||
pandoc["options"].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")
|
|
||||||
if not outfile:
|
|
||||||
LOGGER.error("Could not get output filename from defaults file.")
|
|
||||||
raise typer.Exit()
|
|
||||||
|
|
||||||
# modify output filename (tredition only)
|
|
||||||
if tredition:
|
|
||||||
outfile = str(add_presuffix(outfile, ".tredition"))
|
|
||||||
pandoc["options"].append("--output")
|
|
||||||
pandoc["options"].append(outfile)
|
|
||||||
|
|
||||||
# put together command and run it
|
|
||||||
run_command(pandoc["executable"], ex_opts=pandoc["options"], infiles=input_files)
|
|
||||||
|
|
||||||
# validate epub
|
|
||||||
if validate:
|
|
||||||
epubcheck = OPTIONAL_PROGRAMS["epubcheck"]
|
|
||||||
if epubcheck["executable"]:
|
|
||||||
run_command(epubcheck["executable"], ex_opts=epubcheck["options"], infiles=[outfile])
|
|
||||||
@ -3,12 +3,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from .defaults import main_callback
|
from .defaults import callback
|
||||||
|
|
||||||
# instantiate typer app
|
# instantiate typer app
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
no_args_is_help=True,
|
no_args_is_help=True,
|
||||||
callback=main_callback,
|
callback=callback,
|
||||||
context_settings={"help_option_names": ["-h", "--help"]},
|
context_settings={"help_option_names": ["-h", "--help"]},
|
||||||
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."
|
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."
|
||||||
)
|
)
|
||||||
@ -18,14 +18,6 @@ from .clean import clean_app
|
|||||||
app.add_typer(clean_app)
|
app.add_typer(clean_app)
|
||||||
from .minizine import minizine_app
|
from .minizine import minizine_app
|
||||||
app.add_typer(minizine_app)
|
app.add_typer(minizine_app)
|
||||||
from .cover import cover_app
|
|
||||||
app.add_typer(cover_app)
|
|
||||||
from .epub import epub_app
|
|
||||||
app.add_typer(epub_app)
|
|
||||||
from .pdf import pdf_app
|
|
||||||
app.add_typer(pdf_app)
|
|
||||||
from .all import all_app
|
|
||||||
app.add_typer(all_app)
|
|
||||||
|
|
||||||
# Make CLI runnable from source tree with python src/package
|
# Make CLI runnable from source tree with python src/package
|
||||||
if not __package__:
|
if not __package__:
|
||||||
|
|||||||
@ -10,24 +10,16 @@ from .defaults import *
|
|||||||
minizine_app = typer.Typer()
|
minizine_app = typer.Typer()
|
||||||
|
|
||||||
|
|
||||||
# ##############
|
|
||||||
# TYPER COMMANDS
|
|
||||||
# ##############
|
|
||||||
|
|
||||||
@minizine_app.command()
|
@minizine_app.command()
|
||||||
def minizine(
|
def minizine(
|
||||||
|
ctx: typer.Context,
|
||||||
format: Annotated[
|
format: Annotated[
|
||||||
Format,
|
Format,
|
||||||
typer.Option("--format", "-F", help="Specify output format.")
|
typer.Option("--format", "-f", help="Specify output format.")
|
||||||
] = Format.PDF,
|
] = Format.PDF,
|
||||||
input_dir: Annotated[
|
input_dir: Annotated[
|
||||||
str,
|
str,
|
||||||
typer.Option(
|
typer.Option("--input-dir", "-i", help=f"Specify input directory (either absolute or relative to {str(BASE_DIR)}).")
|
||||||
"--input-dir",
|
|
||||||
"-i",
|
|
||||||
callback=input_dir_callback,
|
|
||||||
help=f"Specify input directory (either absolute or relative to {str(BASE_DIR)})."
|
|
||||||
)
|
|
||||||
] = str(MINIZINE_DIR)
|
] = str(MINIZINE_DIR)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@ -35,11 +27,20 @@ def minizine(
|
|||||||
"""
|
"""
|
||||||
LOGGER.info(f"Building minizine as a {format.upper()} file.")
|
LOGGER.info(f"Building minizine as a {format.upper()} file.")
|
||||||
|
|
||||||
|
# make sure input_dir exists and is not empty
|
||||||
|
input_dir = absolutify(input_dir)
|
||||||
|
if not input_dir.exists():
|
||||||
|
LOGGER.error(f"Input directory {input_dir} does not exist.")
|
||||||
|
return
|
||||||
|
elif not next(input_dir.glob("*"), None):
|
||||||
|
LOGGER.error(f"Input directory {input_dir} is empty.")
|
||||||
|
return
|
||||||
|
|
||||||
# get typst executable
|
# get typst executable
|
||||||
typst = PROGRAMS["typst"]
|
typst = EXECUTABLES["typst"]
|
||||||
|
|
||||||
# get input files
|
# get input files
|
||||||
input_paths = Path(input_dir).glob("*.typ")
|
input_paths = input_dir.glob("*.typ")
|
||||||
|
|
||||||
# run command on each input file
|
# run command on each input file
|
||||||
was_empty = True
|
was_empty = True
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import typer
|
|
||||||
|
|
||||||
from .defaults import *
|
|
||||||
from .cover import cover
|
|
||||||
|
|
||||||
# instantiate typer app
|
|
||||||
pdf_app = typer.Typer()
|
|
||||||
|
|
||||||
|
|
||||||
# ##############
|
|
||||||
# TYPER COMMANDS
|
|
||||||
# ##############
|
|
||||||
|
|
||||||
@pdf_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 pandoc defaults file
|
|
||||||
defaults_file = CONFIG_DIR / f"pandoc-defaults.{Format.PDF}.yaml"
|
|
||||||
if not defaults_file.exists():
|
|
||||||
LOGGER.critical(f"Defaults file {defaults_file} not found.")
|
|
||||||
raise typer.Exit()
|
|
||||||
|
|
||||||
# define executable
|
|
||||||
pandoc = PROGRAMS["pandoc"]
|
|
||||||
pandoc["options"].append("--defaults")
|
|
||||||
pandoc["options"].append(str(defaults_file))
|
|
||||||
|
|
||||||
# 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(str(defaults_file), "output-file")
|
|
||||||
if not outfile:
|
|
||||||
LOGGER.error("Could not get output filename from defaults file.")
|
|
||||||
raise typer.Exit()
|
|
||||||
|
|
||||||
# add presuffix ".tredition" to output filename if flag is set
|
|
||||||
if tredition:
|
|
||||||
outfile = str(add_presuffix(outfile, ".tredition"))
|
|
||||||
pandoc["options"].append("--output")
|
|
||||||
pandoc["options"].append(outfile)
|
|
||||||
|
|
||||||
# make PDf
|
|
||||||
run_command(pandoc["executable"], ex_opts=pandoc["options"], 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
|
|
||||||
Loading…
Reference in New Issue
Block a user