WUT_Computer_Science/scripts/split_tex_sections.py
Krzysztof kuhy Rudnicki ffa5dad02a Split 19 over-cap LaTeX reports, proving each PDF is unchanged
The 250-line cap applies to prose too, and the reports were the worst
offenders -- ENUME's projectA.tex alone was 1,876 lines. Each is now a
preamble plus one \input{} per section, which is how a long LaTeX document is
normally organised; not one byte of prose was rewritten.

"The PDF is unchanged" is checked by a script rather than by eye, because
hashing the PDF cannot work: pdflatex stamps a /CreationDate and an /ID into
every build, so two runs of the SAME source already differ. tex_fingerprint.sh
compares what a reader sees instead -- page count plus a hash of the extracted
text -- and it is stable across rebuilds of untouched sources, which is the
property that makes it usable as a gate.

It earned its keep immediately. Both ECOTE reports quote an example LaTeX
document inside an lstlisting block, so \end{document} appears in the middle
of the file; splitting on the FIRST one stranded everything after it. Same
page count, different text, caught automatically. EOPSY lab3 exposed a second
bug: a \section{} title that runs onto the next line.

Result: 19 .tex over the cap -> 1. The survivor,
ELAC/.../03-building-nfa-...tex, is 418 lines of consecutive tikz figures
with no subsection to split on and needs a hand-placed boundary.

Verified: 29 splits across two passes (section, then subsection for the
pieces still over), every one reporting `unchanged` from
scripts/tex_fingerprint.sh --check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172dj34FDpao6wsix8ZuCzE
2026-08-28 19:47:12 +02:00

211 lines
7.6 KiB
Python
Executable File

#!/usr/bin/env python3
r"""Split an over-long LaTeX report into one ``\input{}`` file per section.
The 250-line cap in this repo applies to prose as well as code, and several
submitted reports are far over it -- ``projectA.tex`` is 1,876 lines. The fix
for a report is structural rather than editorial: move each section into
``sections/`` and leave the parent holding the preamble and a list of
``\input`` lines, which is how a long LaTeX document is normally organised
anyway.
Nothing here rewrites the *content*. Every byte between two section headers is
moved verbatim, so the built PDF is unchanged -- check that with
``scripts/tex_fingerprint.sh --save`` before and ``--check`` after.
Usage::
scripts/split_tex_sections.py report.tex
scripts/split_tex_sections.py report.tex --level chapter --dry-run
"""
from __future__ import annotations
import argparse
import logging
import re
import sys
import unicodedata
from pathlib import Path
# A section header this script is willing to split on. Leading whitespace is
# allowed because several of these reports indent their body one tab.
_HEADER = r"^[ \t]*\\{level}\*?(?:\[[^\]]*\])?\{{"
# \end{document} may likewise be indented, and anything after it belongs to
# the parent file rather than to the last section.
_END_DOCUMENT = re.compile(r"^[ \t]*\\end\{document\}")
_MAX_SLUG_WORDS = 6
# A single section is nothing to split; the parent would just gain a level.
_MIN_SECTIONS = 2
# print() is not available here (ruff T201, no per-file-ignores in this repo),
# so progress goes through logging configured to emit the bare message -- the
# output is identical to a print, and the rule is satisfied rather than muted.
logger = logging.getLogger(__name__)
def _slugify(title: str) -> str:
"""Turn a section title into a short ASCII filename stem.
Polish diacritics are folded rather than dropped, so ``Rozwiązanie
efektywne`` becomes ``rozwiazanie-efektywne`` instead of ``rozwi-zanie``.
"""
# NFKD splits 'ą' into 'a' + a combining ogonek, which the ASCII encode
# then discards. 'ł' has no decomposition, so it is mapped by hand.
folded = unicodedata.normalize("NFKD", title.replace("ł", "l").replace("Ł", "L"))
ascii_only = folded.encode("ascii", "ignore").decode("ascii")
words = re.findall(r"[A-Za-z0-9]+", ascii_only.lower())
return "-".join(words[:_MAX_SLUG_WORDS]) or "section"
def _brace_span(text: str, open_index: int) -> int:
r"""Return the index just past the ``{...}`` group starting at *open_index*.
Counting braces rather than matching ``\{[^}]*\}`` matters: a title may
contain a nested group, as in ``\section{The \emph{hard} part}``.
"""
depth = 0
for i in range(open_index, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return i + 1
msg = "unbalanced braces in a section header"
raise ValueError(msg)
# A title may run onto the following line, as EOPSY lab3 does:
# \\section{Explanation of types of values in summary results and summary
# processes files}
# so the brace scan looks at a small window rather than one line.
_TITLE_WINDOW = 6
def _section_title(lines: list[str], index: int) -> str:
"""Extract the title text of the header starting at *index*."""
window = "".join(lines[index : index + _TITLE_WINDOW])
open_index = window.index("{")
title = window[open_index + 1 : _brace_span(window, open_index) - 1]
return " ".join(title.split())
def find_sections(lines: list[str], level: str) -> list[tuple[int, str]]:
"""Return ``(line_index, title)`` for every header at *level*."""
header = re.compile(_HEADER.format(level=level))
return [
(i, _section_title(lines, i))
for i, line in enumerate(lines)
if header.match(line) and "{" in line
]
def _document_end(lines: list[str]) -> int:
r"""Index of the real ``\end{document}``, or the end of the file.
The LAST match, not the first: ECOTE's two reports quote a whole example
LaTeX document inside an ``lstlisting`` block, so ``\end{document}``
appears in the middle of the file. Taking the first one there stranded
everything after it and silently changed the rendered PDF -- which is
exactly what ``scripts/tex_fingerprint.sh --check`` caught.
"""
for i in range(len(lines) - 1, -1, -1):
if _END_DOCUMENT.match(lines[i]):
return i
return len(lines)
def plan(lines: list[str], level: str) -> list[tuple[str, int, int]]:
"""Work out ``(stem, start, stop)`` for each section, as a half-open range."""
heads = find_sections(lines, level)
end = _document_end(lines)
# The 01-, 02- ... prefix is what keeps the names unique: two sections
# may legitimately share a title, and their slugs would then collide.
return [
(
f"{n + 1:02d}-{_slugify(title)}",
start,
heads[n + 1][0] if n + 1 < len(heads) else end,
)
for n, (start, title) in enumerate(heads)
]
def split(tex: Path, level: str, out: str, prefix: str, *, dry_run: bool) -> int:
"""Split *tex* in place. Returns the number of sections extracted."""
lines = tex.read_text(encoding="utf-8").splitlines(keepends=True)
sections = plan(lines, level)
if len(sections) < _MIN_SECTIONS:
logger.info("%s: %d \\%s found; nothing to split", tex, len(sections), level)
return 0
out_dir = tex.parent / out
first = sections[0][1]
tail = lines[sections[-1][2] :]
body = [f"\t\\input{{{prefix}/{stem}}}\n" for stem, _, _ in sections]
if dry_run:
for stem, start, stop in sections:
logger.info(" %s/%s.tex <- lines %d-%d", out, stem, start + 1, stop)
return len(sections)
out_dir.mkdir(exist_ok=True)
for stem, start, stop in sections:
(out_dir / f"{stem}.tex").write_text(
"".join(lines[start:stop]), encoding="utf-8"
)
tex.write_text("".join([*lines[:first], *body, *tail]), encoding="utf-8")
logger.info("%s: %d sections -> %s/", tex, len(sections), out_dir)
return len(sections)
def main(argv: list[str] | None = None) -> int:
"""Parse arguments and split each named report."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("tex", nargs="+", type=Path, help="report(s) to split")
parser.add_argument(
"--level",
default="section",
choices=("chapter", "section", "subsection"),
help="header level to split on (default: section)",
)
parser.add_argument(
"--out",
default="sections",
help="directory to write the pieces into, beside the .tex (default: sections)",
)
parser.add_argument(
"--input-prefix",
default=None,
help="path used inside \\input{}. LaTeX resolves \\input relative to "
"the MAIN document, not to the including file, so a second-level "
"split needs e.g. --out 03-results --input-prefix sections/03-results "
"(default: the value of --out)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="print the plan without writing anything",
)
args = parser.parse_args(argv)
logging.basicConfig(format="%(message)s", level=logging.INFO, stream=sys.stdout)
for tex in args.tex:
if not tex.is_file():
logger.error("no such file: %s", tex)
return 1
split(
tex,
args.level,
args.out,
args.input_prefix or args.out,
dry_run=args.dry_run,
)
return 0
if __name__ == "__main__":
sys.exit(main())