Files
BlackboxBook/scripts/build_book_pdf.py
busya cc1e796f10 Tidy repo: update gitignore, add sources registry and mermaid deps
- ignore node_modules/ and статьи/ (binary research sources)
- track .github/review-cache/authoritative-sources-by-topic.md
- track package.json/package-lock.json (mermaid-cli)
- commit updated book chapters, build artifacts and build script
2026-08-12 11:02:27 +03:00

719 lines
23 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
ENGINE_CANDIDATES = ("xelatex", "tectonic", "lualatex")
FONT_CANDIDATES = {
"mainfont": (
"Liberation Serif",
"DejaVu Serif",
"Noto Serif",
"STIX Two Text",
"TeX Gyre Schola",
"Charter",
),
"sansfont": (
"PT Sans",
"Helvetica Neue",
"Arial Unicode MS",
"Arial",
"Helvetica",
"Liberation Sans",
"DejaVu Sans",
"Noto Sans",
),
"monofont": (
"Menlo",
"Monaco",
"Courier New",
"Liberation Mono",
"DejaVu Sans Mono",
"Noto Sans Mono",
),
"symbolfont": (
"STIX Two Math",
"STIXGeneral",
"DejaVu Math TeX Gyre",
"Asana Math",
"Noto Sans Symbols 2",
"Symbola",
"FreeSerif",
"Apple Symbols",
"Arial Unicode MS",
"DejaVu Sans",
"Noto Sans",
"Menlo",
),
}
TABLE_SCALE_WORDS = {
"$": "низкая",
"$$": "средняя",
"$$$": "высокая",
"$$$$": "очень высокая",
}
TABLE_SCALE_PATTERN = re.compile(r"(?<=\|\s)(\${1,4})(?=\s*\|)")
CURRENCY_PATTERN = re.compile(r"(?<!\\)\$(?=\d)")
LATEX_COMMAND_PATTERN = re.compile(r"[A-Za-z@]+")
FENCE_PATTERN = re.compile(r"^(`{3,}|~{3,})")
TABLE_SEPARATOR_PATTERN = re.compile(r":?-{3,}:?")
LONGTABLE_BODY_PATTERN = re.compile(r"(\\endlastfoot\s*)(.*?)(\\end\{longtable\})", re.S)
@dataclass(frozen=True)
class LayoutProfile:
section_font_size: str | None = None
subsection_font_size: str | None = None
subsubsection_font_size: str | None = None
table_font_size: str | None = None
table_padding: str | None = None
array_stretch: float | None = None
emergency_stretch: str | None = None
sloppy: bool = False
reflow_table_columns: int | None = None
LAYOUT_PROFILES = {
"default": LayoutProfile(),
"ebook": LayoutProfile(
section_font_size=r"\Large",
subsection_font_size=r"\large",
subsubsection_font_size=r"\normalsize",
emergency_stretch="2em",
),
"phone": LayoutProfile(
section_font_size=r"\Large",
subsection_font_size=r"\normalsize",
subsubsection_font_size=r"\normalsize",
table_font_size=r"\scriptsize",
table_padding="2pt",
array_stretch=1.02,
emergency_stretch="3em",
sloppy=True,
reflow_table_columns=3,
),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build a PDF from a Markdown book without touching the source files.",
)
parser.add_argument(
"--source",
default="book",
help="Directory or Markdown file to convert. Default: %(default)s",
)
parser.add_argument(
"--output",
default=f"{Path.cwd().name}.pdf",
help="Output PDF path. Default: %(default)s",
)
parser.add_argument(
"--engine",
default="auto",
help="PDF engine: auto or xelatex. Default: %(default)s",
)
parser.add_argument(
"--no-toc",
action="store_true",
help="Disable the generated table of contents.",
)
parser.add_argument(
"--keep-temp",
action="store_true",
help="Keep the temporary sanitized copy after a successful build.",
)
parser.add_argument(
"--margin",
default="1in",
help="Page margin passed to LaTeX geometry. Default: %(default)s",
)
parser.add_argument(
"--page-width",
help="Custom page width (for example: 6in). Use together with --page-height.",
)
parser.add_argument(
"--page-height",
help="Custom page height (for example: 8in). Use together with --page-width.",
)
parser.add_argument(
"--wrap-code-blocks",
action="store_true",
help="Wrap long lines inside code blocks in the generated PDF.",
)
parser.add_argument(
"--code-font-size",
help="LaTeX font size command for code blocks, for example: footnotesize.",
)
parser.add_argument(
"--layout-profile",
choices=tuple(LAYOUT_PROFILES),
default="default",
help="Extra narrow-layout tuning for PDF output. Default: %(default)s",
)
return parser.parse_args()
def resolve_engine(requested: str) -> str:
if requested != "auto":
if requested != "xelatex":
raise SystemExit("This build requires xelatex for stable Unicode symbol rendering.")
if shutil.which(requested):
return requested
raise SystemExit(f"PDF engine '{requested}' was not found in PATH.")
for candidate in ENGINE_CANDIDATES:
if shutil.which(candidate):
return candidate
raise SystemExit("No supported PDF engine found. Install xelatex.")
def ensure_pandoc() -> None:
if not shutil.which("pandoc"):
raise SystemExit("pandoc was not found in PATH.")
def resolve_page_geometry(args: argparse.Namespace) -> tuple[str | None, str]:
has_width = bool(args.page_width)
has_height = bool(args.page_height)
if has_width != has_height:
raise SystemExit("Both --page-width and --page-height must be provided together.")
if has_width and has_height:
return None, f"paperwidth={args.page_width},paperheight={args.page_height},margin={args.margin}"
return "a4", f"margin={args.margin}"
def normalize_latex_command(value: str, *, option_name: str) -> str:
command = value.lstrip("\\")
if not command or not LATEX_COMMAND_PATTERN.fullmatch(command):
raise SystemExit(f"Invalid {option_name} value: {value}")
return f"\\{command}"
def create_header_include(
temp_root: Path,
args: argparse.Namespace,
layout_profile: LayoutProfile,
fonts: dict[str, str],
) -> Path | None:
fv_options: list[str] = []
if args.wrap_code_blocks:
fv_options.extend(["breaklines=true", "breakanywhere=true"])
if args.code_font_size:
fv_options.append(
f"fontsize={normalize_latex_command(args.code_font_size, option_name='--code-font-size')}"
)
header_lines: list[str] = []
symbolfont = fonts.get("symbolfont") or fonts.get("monofont")
if symbolfont:
header_lines.extend(
[
r"\usepackage{etoolbox}",
r"\usepackage{ucharclasses}",
rf"\newfontfamily\BookSymbolFont{{{symbolfont}}}[Scale=MatchLowercase,ItalicFont={{{symbolfont}}},BoldFont={{{symbolfont}}},BoldItalicFont={{{symbolfont}}}]",
r"\setTransitionsForSymbols{\begingroup\BookSymbolFont}{\endgroup}",
r"\setTransitionsForGreek{\begingroup\BookSymbolFont}{\endgroup}",
r"\setTransitionsForMathematics{\begingroup\BookSymbolFont}{\endgroup}",
r"\setTransitionsFor{SuperscriptsAndSubscripts}{\begingroup\BookSymbolFont}{\endgroup}",
r"\AtBeginEnvironment{verbatim}{\XeTeXinterchartokenstate=0}",
r"\AfterEndEnvironment{verbatim}{\XeTeXinterchartokenstate=1}",
r"\AtBeginEnvironment{Verbatim}{\XeTeXinterchartokenstate=0}",
r"\AfterEndEnvironment{Verbatim}{\XeTeXinterchartokenstate=1}",
"",
]
)
if fv_options:
header_lines.extend(
[
r"\IfFileExists{fvextra.sty}{",
r"\usepackage{fvextra}",
rf"\fvset{{{','.join(fv_options)}}}",
r"}{",
r"}",
"",
]
)
if layout_profile != LAYOUT_PROFILES["default"]:
header_lines.extend(
[
r"\IfFileExists{xurl.sty}{\usepackage{xurl}}{}",
r"\IfFileExists{microtype.sty}{\usepackage{microtype}}{}",
r"\IfFileExists{etoolbox.sty}{\usepackage{etoolbox}}{}",
r"\makeatletter",
r"\@ifundefined{Urlmuskip}{}{\Urlmuskip=0mu plus 1mu}",
r"\AtBeginDocument{\@ifundefined{LTleft}{}{\setlength{\LTleft}{0pt}\setlength{\LTright}{0pt}}}",
r"\makeatother",
]
)
if layout_profile.emergency_stretch:
header_lines.append(
rf"\setlength{{\emergencystretch}}{{{layout_profile.emergency_stretch}}}"
)
if layout_profile.table_padding:
header_lines.append(rf"\setlength{{\tabcolsep}}{{{layout_profile.table_padding}}}")
if layout_profile.array_stretch is not None:
header_lines.append(rf"\renewcommand{{\arraystretch}}{{{layout_profile.array_stretch:.2f}}}")
if layout_profile.sloppy:
header_lines.append(r"\sloppy")
if layout_profile.table_font_size:
header_lines.extend(
[
r"\IfFileExists{etoolbox.sty}{",
rf"\AtBeginEnvironment{{longtable}}{{{layout_profile.table_font_size}}}",
rf"\AtBeginEnvironment{{tabular}}{{{layout_profile.table_font_size}}}",
r"}{",
r"}",
]
)
if any(
value
for value in (
layout_profile.section_font_size,
layout_profile.subsection_font_size,
layout_profile.subsubsection_font_size,
)
):
header_lines.extend(
[
r"\IfFileExists{titlesec.sty}{",
r"\usepackage{titlesec}",
]
)
if layout_profile.section_font_size:
header_lines.extend(
[
rf"\titleformat{{\section}}[block]{{\bfseries {layout_profile.section_font_size}}}{{\thesection}}{{0.6em}}{{}}",
r"\titlespacing*{\section}{0pt}{1.6ex plus 0.4ex minus 0.2ex}{0.8ex plus 0.2ex}",
]
)
if layout_profile.subsection_font_size:
header_lines.extend(
[
rf"\titleformat{{\subsection}}[block]{{\bfseries {layout_profile.subsection_font_size}}}{{\thesubsection}}{{0.6em}}{{}}",
r"\titlespacing*{\subsection}{0pt}{1.2ex plus 0.3ex minus 0.1ex}{0.6ex plus 0.2ex}",
]
)
if layout_profile.subsubsection_font_size:
header_lines.extend(
[
rf"\titleformat{{\subsubsection}}[block]{{\bfseries {layout_profile.subsubsection_font_size}}}{{\thesubsubsection}}{{0.6em}}{{}}",
r"\titlespacing*{\subsubsection}{0pt}{1.0ex plus 0.3ex minus 0.1ex}{0.5ex plus 0.1ex}",
]
)
header_lines.extend([r"}{", r"}"])
if not header_lines:
return None
header_path = temp_root / "pdf-header.tex"
header_path.write_text("\n".join([*header_lines, ""]), encoding="utf-8")
return header_path
def natural_sort_key(path: Path, root: Path) -> list[tuple[int, object]]:
key: list[tuple[int, object]] = []
for part in path.relative_to(root).parts:
for chunk in re.split(r"(\d+)", part.casefold()):
if not chunk:
continue
if chunk.isdigit():
key.append((0, int(chunk)))
else:
key.append((1, chunk))
return key
def discover_markdown_files(source: Path) -> tuple[Path, list[Path]]:
if source.is_file():
if source.suffix.lower() != ".md":
raise SystemExit(f"Source file must be Markdown: {source}")
return source.parent, [source]
if not source.is_dir():
raise SystemExit(f"Source path does not exist: {source}")
files = [
path
for path in source.rglob("*.md")
if ".git" not in path.parts
and not any(part.startswith(".") for part in path.relative_to(source).parts)
]
files.sort(key=lambda path: natural_sort_key(path, source))
if not files:
raise SystemExit(f"No Markdown files found under: {source}")
return source, files
def ignore_copy_items(_: str, names: list[str]) -> list[str]:
ignored: list[str] = []
for name in names:
if name in {".git", "__pycache__", ".DS_Store"}:
ignored.append(name)
continue
if name.startswith("."):
ignored.append(name)
return ignored
def pick_fonts() -> dict[str, str]:
fc_list = shutil.which("fc-list")
if not fc_list:
return {}
proc = subprocess.run([fc_list], capture_output=True, text=True, check=True)
installed = proc.stdout
picked: dict[str, str] = {}
for key, candidates in FONT_CANDIDATES.items():
for candidate in candidates:
if candidate in installed:
picked[key] = candidate
break
return picked
def split_pipe_table_row(line: str) -> list[str] | None:
stripped = line.strip()
if not stripped.startswith("|") or not stripped.endswith("|"):
return None
return [cell.strip() for cell in stripped[1:-1].split("|")]
def is_pipe_table_separator(cells: list[str]) -> bool:
return bool(cells) and all(
TABLE_SEPARATOR_PATTERN.fullmatch(cell.replace(" ", "")) for cell in cells
)
def render_reflowed_table(headers: list[str], rows: list[list[str]]) -> list[str]:
rendered: list[str] = []
detail_headers = headers[1:]
for row in rows:
title = row[0] or headers[0] or "Пункт"
rendered.append(f"**{title}**")
for header, cell in zip(detail_headers, row[1:]):
rendered.append(f"- {header}: {cell or ''}")
rendered.append("")
if rendered and rendered[-1] == "":
rendered.pop()
return rendered
def reflow_wide_tables(text: str, min_columns: int) -> str:
lines = text.splitlines()
transformed_lines: list[str] = []
inside_fence = False
fence_marker = ""
index = 0
while index < len(lines):
line = lines[index]
fence_match = FENCE_PATTERN.match(line.strip())
if fence_match:
marker = fence_match.group(1)[0]
if inside_fence and marker == fence_marker:
inside_fence = False
fence_marker = ""
elif not inside_fence:
inside_fence = True
fence_marker = marker
transformed_lines.append(line)
index += 1
continue
if inside_fence or index + 1 >= len(lines):
transformed_lines.append(line)
index += 1
continue
header = split_pipe_table_row(line)
separator = split_pipe_table_row(lines[index + 1])
if not header or not separator or len(header) != len(separator) or not is_pipe_table_separator(separator):
transformed_lines.append(line)
index += 1
continue
row_index = index + 2
rows: list[list[str]] = []
while row_index < len(lines):
row = split_pipe_table_row(lines[row_index])
if not row or len(row) != len(header):
break
rows.append(row)
row_index += 1
if len(header) < min_columns or not rows:
transformed_lines.append(line)
index += 1
continue
transformed_lines.extend(render_reflowed_table(header, rows))
if row_index < len(lines) and lines[row_index].strip():
transformed_lines.append("")
index = row_index
trailing_newline = "\n" if text.endswith("\n") else ""
return "\n".join(transformed_lines) + trailing_newline
def sanitize_markdown(text: str, layout_profile: LayoutProfile) -> str:
sanitized_lines: list[str] = []
inside_fence = False
fence_marker = ""
for line in text.splitlines():
fence_match = FENCE_PATTERN.match(line.strip())
if fence_match:
marker = fence_match.group(1)[0]
if inside_fence and marker == fence_marker:
inside_fence = False
fence_marker = ""
elif not inside_fence:
inside_fence = True
fence_marker = marker
sanitized_lines.append(line)
continue
if inside_fence:
sanitized_lines.append(line)
continue
if line.strip() != "$$":
line = TABLE_SCALE_PATTERN.sub(lambda match: TABLE_SCALE_WORDS[match.group(1)], line)
line = CURRENCY_PATTERN.sub(r"\\$", line)
sanitized_lines.append(line)
sanitized = "\n".join(sanitized_lines) + ("\n" if text.endswith("\n") else "")
if layout_profile.reflow_table_columns:
sanitized = reflow_wide_tables(sanitized, layout_profile.reflow_table_columns)
return sanitized
def build_temp_tree(
root: Path,
markdown_files: list[Path],
temp_root: Path,
layout_profile: LayoutProfile,
) -> tuple[Path, list[Path]]:
temp_source = temp_root / root.name
shutil.copytree(root, temp_source, ignore=ignore_copy_items, dirs_exist_ok=True)
temp_markdown_files: list[Path] = []
for file_path in markdown_files:
temp_file = temp_source / file_path.relative_to(root)
temp_file.write_text(
sanitize_markdown(temp_file.read_text(encoding="utf-8"), layout_profile),
encoding="utf-8",
)
temp_markdown_files.append(temp_file)
return temp_source, temp_markdown_files
def add_longtable_row_rules(latex: str) -> str:
def replace(match: re.Match[str]) -> str:
body = match.group(2)
lines = body.splitlines(keepends=True)
row_indices = [index for index, line in enumerate(lines) if line.rstrip().endswith(r"\\")]
for index in row_indices[:-1]:
next_line = lines[index + 1] if index + 1 < len(lines) else ""
if r"\midrule" in next_line or r"\specialrule" in next_line:
continue
lines[index] = lines[index] + r"\midrule" + "\n"
return match.group(1) + "".join(lines) + match.group(3)
return LONGTABLE_BODY_PATTERN.sub(replace, latex)
def compile_latex_document(tex_path: Path, workdir: Path, output: Path, engine: str) -> None:
for _ in range(2):
subprocess.run(
[engine, "-interaction=nonstopmode", "-halt-on-error", tex_path.name],
cwd=workdir,
check=True,
capture_output=True,
text=True,
)
generated_pdf = tex_path.with_suffix(".pdf")
shutil.copy2(generated_pdf, output)
def run_pandoc(
markdown_files: list[Path],
source_root: Path,
output: Path,
engine: str,
fonts: dict[str, str],
toc: bool,
papersize: str | None,
geometry: str,
header_include: Path | None,
temp_root: Path,
) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
tex_path = temp_root / "book.tex"
command = ["pandoc", *[str(path) for path in markdown_files]]
command.extend(["--resource-path", os.pathsep.join([str(source_root), str(source_root.parent)])])
command.append("--standalone")
if toc:
command.append("--toc")
if header_include:
command.extend(["--include-in-header", str(header_include)])
command.extend(
[
"-V",
"documentclass=book",
]
)
if papersize:
command.extend(["-V", f"papersize={papersize}"])
command.extend(["-V", f"geometry:{geometry}"])
for key in ("mainfont", "sansfont", "monofont"):
value = fonts.get(key)
if value:
command.extend(["-V", f"{key}={value}"])
command.extend(["-t", "latex", "-o", str(tex_path)])
command.extend(["--filter", "pandoc-mermaid"])
subprocess.run(command, check=True)
mermaid_src = Path.cwd() / "mermaid-images"
mermaid_dst = temp_root / "mermaid-images"
if mermaid_src.is_dir():
if mermaid_dst.exists():
shutil.rmtree(mermaid_dst, ignore_errors=True)
shutil.copytree(str(mermaid_src), str(mermaid_dst))
tex_path.write_text(add_longtable_row_rules(tex_path.read_text(encoding="utf-8")), encoding="utf-8")
compile_latex_document(tex_path, temp_root, output, engine)
def main() -> int:
args = parse_args()
ensure_pandoc()
source = Path(args.source).expanduser().resolve()
output = Path(args.output).expanduser().resolve()
engine = resolve_engine(args.engine)
layout_profile = LAYOUT_PROFILES[args.layout_profile]
fonts = pick_fonts()
if "symbolfont" not in fonts:
fallback = fonts.get("sansfont") or fonts.get("monofont")
if fallback:
fonts["symbolfont"] = fallback
print(
f"Warning: no dedicated symbol font found; using '{fallback}' as fallback.",
file=sys.stderr,
)
else:
raise SystemExit(
"No symbol-capable fallback font found. Install STIX Two Math, DejaVu Math TeX Gyre, Noto Sans Symbols 2, Apple Symbols, or Arial Unicode MS."
)
papersize, geometry = resolve_page_geometry(args)
root, markdown_files = discover_markdown_files(source)
temp_root = Path(tempfile.mkdtemp(prefix="book-pdf-"))
build_succeeded = False
try:
header_include = create_header_include(temp_root, args, layout_profile, fonts)
temp_source, temp_markdown_files = build_temp_tree(
root,
markdown_files,
temp_root,
layout_profile,
)
print(f"Source root: {root}")
print(f"Markdown files: {len(markdown_files)}")
print(f"PDF engine: {engine}")
print(f"Layout profile: {args.layout_profile}")
if papersize:
print(f"Paper size: {papersize}")
else:
print(f"Page size: {args.page_width} x {args.page_height}")
print(f"Geometry: {geometry}")
if header_include:
print(f"Header include: {header_include}")
if fonts:
for key in ("mainfont", "sansfont", "monofont", "symbolfont"):
if key in fonts:
print(f"{key}: {fonts[key]}")
run_pandoc(
markdown_files=temp_markdown_files,
source_root=temp_source,
output=output,
engine=engine,
fonts=fonts,
toc=not args.no_toc,
papersize=papersize,
geometry=geometry,
header_include=header_include,
temp_root=temp_root,
)
build_succeeded = True
except subprocess.CalledProcessError as error:
print(f"PDF build failed with exit code {error.returncode}.", file=sys.stderr)
if error.stdout:
print(error.stdout, file=sys.stderr)
if error.stderr:
print(error.stderr, file=sys.stderr)
print(f"Temporary files kept at: {temp_root}", file=sys.stderr)
return error.returncode
finally:
if build_succeeded and not args.keep_temp:
shutil.rmtree(temp_root, ignore_errors=True)
print(f"PDF written to: {output}")
return 0
if __name__ == "__main__":
sys.exit(main())