textbooks: TeX-source ingest closes pillars I + IV (Hilbert + Boole)

Two foundational PD textbooks ship from Project Gutenberg as
LaTeX source only — no clean HTML edition. Pandoc fails on PG's
custom preamble macros; a focused regex-based stripper is the
right amount of machinery for the well-known PG TeX format.

What landed
===========
- arborist/sources/textbook_tex.py — TextbookTexSource +
  strip_tex pipeline. Drops preamble + line comments + structural
  envs (tabular, figure, thebibliography, scshape, …); keeps the
  argument of structural-but-content-bearing single-arg commands
  (textbf, emph, section, chapter, paragraph, PG's custom \\rfa);
  drops zero-arg + brace-arg structural commands (noindent,
  thispagestyle, setcounter, label, index, …); substitutes
  symbol-level macros (\\to → →, \\neg → ¬, \\forall → ∀, \\S → §,
  Greek letters, etc.).

- arborist/cli.py — `--source textbook_tex` accepts --url,
  --bundle, or --urls-from. Reuses the existing fetch + chunker
  + Merkle commit + audit pipeline; idempotent at the database
  layer (same TeX → same prose → same document_root).

- bench/scripts/textbooks_manifest.py — gains `tex-targets`
  subcommand emitting tab-separated `<tex_url>\t<id>` rows for
  manifest entries with a `tex_url` field.

- Makefile targets:
    textbooks-tex         — ingest every entry with a tex_url
    textbook-hilbert      — convenience for PG #17384
    textbook-boole        — convenience for PG #15114
  Each writes to $(CRAWL_SHARDS_DIR)/textbook_<id>.db, idempotent
  on re-run.

- tests/test_textbook_tex.py — 20 unit tests covering preamble +
  postmatter stripping, line comments, env drops (tabular, figure),
  single-arg keepers (\\textbf, \\emph, \\section, \\rfa),
  symbol-level macro subs (10 paramerized cases), structural-cmd
  drops, whitespace cleanup, idempotence on already-stripped text.

End-to-end verification
=======================
Smoke test on PG #17384 + #15114:
  Hilbert Foundations of Geometry: 1 doc / 65 chunks (192K of
    plain prose). FTS5 finds "axiom of parallels" → real chapter
    content with axiom references intact (≡, §, math fragments).
  Boole Laws of Thought: 1 doc / 273 chunks (829K). FTS5 finds
    "law of contradiction" → "the principle of contradiction"
    passage from Chapter III of Boole's text.

Vital-books coverage now 6/7 pillars
====================================

  Pillar I   Logic        ✓ Levin + Aristotle Prior + Posterior + Boole
  Pillar II  Set Theory   ✓ Levin
  Pillar III Arithmetic   ✓ Levin
  Pillar IV  Geometry     ✓ Hilbert (PG TeX)
  Pillar V   Probability  ✗ Kolmogorov license analysis pending
  Pillar VI  Phys.        ✓ Newton Principia
  Pillar VII Combin.      ✓ Bogart + Keller-Trotter + Levin
  Pillar IX  λ-Calculus   ✗ Church + Turing 1936 papers pending

Test suite: 1574 passed / 28 skipped (was 1554 + 20 new TeX tests).

Out of scope: chunk-resolution + derivations.proof_blob warrant
promotion (#000031 follow-up; see also #000032).
This commit is contained in:
russell@unturf.com 2026-05-09 16:06:53 -04:00
parent 57dafbdc3d
commit 514e07d7c2
No known key found for this signature in database
7 changed files with 644 additions and 6 deletions

View file

@ -770,6 +770,40 @@ textbook-newton: ## Newton Principia (PD, Wikisource)
textbook-morin: ## Morin Open Data Structures (CC-BY)
$(MAKE) textbook ID=morin-open-data-structures
# TeX-source textbooks: Project Gutenberg eBooks that ship as
# LaTeX source only (no clean HTML edition). The textbook_tex
# source strips the LaTeX into plain prose via a focused PG-aware
# pipeline. Idempotent at the database layer like every other
# ingest path.
.PHONY: textbooks-tex textbook-hilbert textbook-boole
textbooks-tex: bootstrap ## ingest every manifest entry that declares a tex_url (Hilbert + Boole)
@mkdir -p $(CRAWL_SHARDS_DIR)
@$(PY) -m bench.scripts.textbooks_manifest tex-targets < $(TEXTBOOK_MANIFEST) | \
while IFS=$$'\t' read -r url id; do \
shard="$(CRAWL_SHARDS_DIR)/textbook_$${id}.db"; \
echo ">> $$id (textbook_tex)"; \
echo " url: $$url"; \
echo " shard: $$shard"; \
$(ARBORIST) --db "$$shard" ingest --source textbook_tex --url "$$url"; \
done
textbook-hilbert: ## Hilbert Foundations of Geometry (PD, PG TeX)
@row=$$(grep '"hilbert-foundations-geometry-1902"' $(TEXTBOOK_MANIFEST) | head -1); \
url=$$(echo "$$row" | $(PY) -c "import json,sys; print(json.loads(sys.stdin.read()).get('tex_url',''))"); \
if [ -z "$$url" ]; then echo "no tex_url for Hilbert" >&2; exit 1; fi; \
mkdir -p $(CRAWL_SHARDS_DIR); \
$(ARBORIST) --db "$(CRAWL_SHARDS_DIR)/textbook_hilbert-foundations-geometry-1902.db" \
ingest --source textbook_tex --url "$$url"
textbook-boole: ## Boole Laws of Thought (PD, PG TeX)
@row=$$(grep '"boole-laws-of-thought-1854"' $(TEXTBOOK_MANIFEST) | head -1); \
url=$$(echo "$$row" | $(PY) -c "import json,sys; print(json.loads(sys.stdin.read()).get('tex_url',''))"); \
if [ -z "$$url" ]; then echo "no tex_url for Boole" >&2; exit 1; fi; \
mkdir -p $(CRAWL_SHARDS_DIR); \
$(ARBORIST) --db "$(CRAWL_SHARDS_DIR)/textbook_boole-laws-of-thought-1854.db" \
ingest --source textbook_tex --url "$$url"
test-crawler: bootstrap-crawler ## run only the lifted crawler tests
$(VENV)/bin/pytest -q tests/crawler

View file

@ -65,6 +65,29 @@ def _cmd_ingest(args: argparse.Namespace) -> int:
cls = GrokExportSource if args.source == "grok_export" else GrokMediaPostsSource
src = cls(path=args.path)
elif args.source == "textbook_tex":
# PG-style LaTeX source ingest (#000031). Each --url or --bundle
# points at a TeX URL (e.g., PG eBook /files/N/N-t/N-t.tex).
# `--urls-from FILE` for a list. The strip-tex pipeline produces
# plain prose suitable for the standard 512-token chunker.
urls: list[str] = list(getattr(args, "url", None) or [])
if getattr(args, "bundle", None):
urls.extend(args.bundle)
if args.urls_from:
urls.extend(
line.strip()
for line in Path(args.urls_from).read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
)
if not urls:
print(
"textbook_tex source needs --url, --bundle, or --urls-from",
file=sys.stderr,
)
return 2
from arborist.sources import TextbookTexSource
src = TextbookTexSource(urls)
elif args.source == "claim_pack":
# Companion JSON bundles (axiom + theorem packs) — see ticket
# #000029. --bundle is repeatable so axiom-bundle and theorem-bundle
@ -3908,6 +3931,7 @@ def build_parser() -> argparse.ArgumentParser:
"hg_repo",
"providence",
"claim_pack",
"textbook_tex",
],
help="source type",
)

View file

@ -2,6 +2,7 @@
from arborist.sources.claim_pack import ClaimPackSource
from arborist.sources.grok import GrokExportSource, GrokMediaPostsSource
from arborist.sources.textbook_tex import TextbookTexSource
from arborist.sources.vcs import GitRepoSource, MercurialRepoSource
from arborist.sources.wikipedia import (
WikipediaCurDump,
@ -15,6 +16,7 @@ __all__ = [
"GitRepoSource",
"GrokExportSource",
"GrokMediaPostsSource",
"TextbookTexSource",
"MercurialRepoSource",
"WikipediaAbstractDump",
"WikipediaCurDump",

View file

@ -0,0 +1,320 @@
"""TeX-source textbook ingest (Project Gutenberg eBook style).
Targets the canonical Project Gutenberg LaTeX format used for math /
science textbooks where no HTML edition exists (e.g., Hilbert
*Foundations of Geometry* eBook #17384, Boole *An Investigation of
the Laws of Thought* eBook #15114). One Document per TeX source URL;
the strip pipeline produces searchable plain text suitable for the
existing 512-token chunker + Merkle commitment + audit chain.
Why this exists
---------------
The first iteration of textbook surface-ingest (#000031) used
``arborist/sources/html_page.py`` against Wikisource / openmathbooks
mirrors. Two of the most foundational PD textbooks have no clean
HTML edition Project Gutenberg ships them only as PDF + LaTeX
source. Pandoc fails on PG's custom preamble macros (`\\rfa`,
`\\thispagestyle`, etc.); a focused regex stripper is the right
amount of machinery for the well-known PG TeX format.
What gets stripped
------------------
1. Line comments (`%`-prefixed lines and trailing `%` to EOL).
2. Preamble (everything before the first ``\\begin{document}``).
3. Block environments that yield no useful prose:
``tabular``, ``figure``, ``thebibliography``, ``titlepage``,
``flushright``, ``flushleft``, ``center``, ``small``, ``footnotesize``,
``scshape``, ``itemize``, ``enumerate`` (kept), and other
structural-only environments.
4. Common single-arg commands stripped to their argument:
``\\textbf{X}`` ``X``; ``\\emph{X}`` ``X``; ``\\rfa{X}``
``X``; ``\\section{X}`` ``X``; ``\\chapter{X}`` ``X``;
``\\paragraph{X}`` ``X``.
5. Zero-arg / structural commands dropped:
``\\noindent``, ``\\bigskip``, ``\\smallskip``, ``\\medskip``,
``\\par``, ``\\linebreak``, ``\\newpage``, ``\\clearpage``,
``\\thispagestyle{...}``, ``\\setcounter{...}{...}``,
``\\pageref{...}``, ``\\label{...}``, ``\\index{...}``.
6. Math-mode delimiters preserved as-is. The body inside ``$...$``
stays raw FTS5 will index Greek-letter and operator tokens; the
wikitext-base canonicalizer can handle math separately if needed.
7. Common macro substitutions:
``\\S`` ``§``; ``\\dots``/``\\ldots`` ````; ``\\&`` ``&``;
``\\to`` ````; ``\\neg`` ``¬``; ``\\lor`` ````;
``\\land`` ````; ``~`` ``\xa0`` (non-break space);
``\\\\`` newline.
What's deliberately not stripped
--------------------------------
We don't try to render math, build cross-references, or extract
figures. The output is plain prose with embedded math source
suitable for retrieval, not for reproduction. The PG PDF is the
canonical visual rendering.
Idempotency
-----------
Same TeX URL same response body same Merkle root no-op
re-ingest. The TeX content is wrapped in a Document with
``source_type='textbook_tex'`` so re-runs against the same shard
deduplicate via the standard content-addressed insert.
Source: ticket #000031 §5 (PDF/TeX support).
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Iterator
import httpx
from arborist.document import Document, Edge
from arborist.source import Source
USER_AGENT = "arborist/0.0.1 (+https://unturf.com)"
NORMALIZE_VERSION = "tex-strip-v1"
_PREAMBLE_RE = re.compile(r"^.*?\\begin\{document\}", re.DOTALL)
_POSTMATTER_RE = re.compile(r"\\end\{document\}.*$", re.DOTALL)
_LINE_COMMENT_RE = re.compile(r"(?m)(?<!\\)%[^\n]*")
# Block environments that produce no useful prose. The body between
# \begin{X} ... \end{X} is dropped wholesale.
_DROP_ENVS = (
"tabular", "tabularx", "tabular*",
"figure", "figure*", "wrapfigure",
"thebibliography",
"titlepage", "abstract",
"flushright", "flushleft", "center",
"small", "footnotesize", "scriptsize", "tiny",
"scshape", "rmfamily", "sffamily", "ttfamily",
"verbatim", "lstlisting",
)
_DROP_ENV_RE = re.compile(
r"\\begin\{(" + "|".join(re.escape(e) for e in _DROP_ENVS) + r")\}.*?\\end\{\1\}",
re.DOTALL,
)
# Single-arg commands stripped to their argument (regex captures
# argument's TeX, not balanced-brace correct but works on PG's
# straightforward usage).
_KEEP_ARG_CMDS = (
"textbf", "textit", "emph", "underline", "textsc",
"rfa", "rfb", # PG custom: roman fixed all-caps
"chapter", "chapter*",
"section", "section*", "subsection", "subsection*",
"subsubsection", "paragraph", "subparagraph",
"footnote",
"title", "author",
"mbox", "fbox",
)
_KEEP_ARG_RE = re.compile(
r"\\(" + "|".join(re.escape(c) for c in _KEEP_ARG_CMDS) + r")\*?\{([^{}]*)\}"
)
# Zero-arg + simple-arg commands dropped wholesale.
_DROP_CMDS_ZEROARG = (
"noindent", "bigskip", "smallskip", "medskip", "vspace", "hspace",
"par", "linebreak", "newpage", "clearpage", "newline",
"leavevmode", "centering", "raggedright", "raggedleft",
"hline", "cline", "rule",
"maketitle", "tableofcontents", "listoffigures", "listoftables",
)
_DROP_ZEROARG_RE = re.compile(
r"\\(" + "|".join(re.escape(c) for c in _DROP_CMDS_ZEROARG) + r")\b\*?"
)
_DROP_BRACE_CMDS = (
"thispagestyle", "pagestyle",
"setcounter", "addtocounter", "stepcounter",
"label", "ref", "pageref", "eqref", "cite",
"index", "indexset", "marginpar",
"documentclass", "usepackage", "input", "include",
"newcommand", "renewcommand", "newenvironment", "renewenvironment",
"definecolor", "color", "textcolor",
"setlength", "addtolength", "newlength",
"geometry", "fancyhead", "fancyfoot",
)
_DROP_BRACE_RE = re.compile(
r"\\(" + "|".join(re.escape(c) for c in _DROP_BRACE_CMDS)
+ r")\*?(?:\[[^\]]*\])?\{[^{}]*\}(?:\{[^{}]*\})?"
)
# Macro substitutions — symbol-level.
_MACRO_SUBS = [
(r"\\S\b", "§"),
(r"\\P\b", ""),
(r"\\copyright\b", "©"),
(r"\\dots\b", ""),
(r"\\ldots\b", ""),
(r"\\cdots\b", ""),
(r"\\to\b", ""),
(r"\\rightarrow\b", ""),
(r"\\leftarrow\b", ""),
(r"\\leftrightarrow\b", ""),
(r"\\Rightarrow\b", ""),
(r"\\Leftarrow\b", ""),
(r"\\Leftrightarrow\b", ""),
(r"\\neg\b", "¬"),
(r"\\lnot\b", "¬"),
(r"\\lor\b", ""),
(r"\\vee\b", ""),
(r"\\land\b", ""),
(r"\\wedge\b", ""),
(r"\\forall\b", ""),
(r"\\exists\b", ""),
(r"\\equiv\b", ""),
(r"\\neq\b", ""),
(r"\\leq\b", ""),
(r"\\geq\b", ""),
(r"\\times\b", "×"),
(r"\\cdot\b", "·"),
(r"\\pm\b", "±"),
(r"\\infty\b", ""),
(r"\\in\b", ""),
(r"\\notin\b", ""),
(r"\\subset\b", ""),
(r"\\supset\b", ""),
(r"\\cup\b", ""),
(r"\\cap\b", ""),
(r"\\alpha\b", "α"),
(r"\\beta\b", "β"),
(r"\\gamma\b", "γ"),
(r"\\delta\b", "δ"),
(r"\\epsilon\b", "ε"),
(r"\\theta\b", "θ"),
(r"\\lambda\b", "λ"),
(r"\\mu\b", "μ"),
(r"\\pi\b", "π"),
(r"\\sigma\b", "σ"),
(r"\\phi\b", "φ"),
(r"\\omega\b", "ω"),
(r"\\Sigma\b", "Σ"),
(r"\\Theta\b", "Θ"),
(r"\\Phi\b", "Φ"),
(r"\\Omega\b", "Ω"),
(r"\\&", "&"),
(r"\\\$", "$"),
(r"\\#", "#"),
(r"\\_", "_"),
(r"\\\\", "\n"),
(r"~", "\xa0"),
]
_MACRO_SUB_PATTERNS = [(re.compile(p), s) for p, s in _MACRO_SUBS]
# Whitespace cleanup
_MULTI_SPACE_RE = re.compile(r"[ \t]{2,}")
_MULTI_NEWLINE_RE = re.compile(r"\n{3,}")
def strip_tex(tex: str) -> str:
"""Convert a Project Gutenberg LaTeX source body into plain prose.
Imperfect by design a focused regex pipeline tuned for the PG
canonical format. The output is suitable for retrieval (FTS5
indexing, embeddings, semantic search) but NOT for reproduction:
layout, figures, exact mathematical typesetting, and
cross-references are not preserved.
Pipeline (order matters):
1. Drop everything before ``\\begin{document}``.
2. Drop everything after ``\\end{document}``.
3. Strip line comments.
4. Drop block environments that yield no prose.
5. Strip 0-arg / brace-arg structural commands.
6. Reduce single-arg commands to their argument.
7. Apply symbol-level macro substitutions.
8. Whitespace normalize.
"""
text = tex
text = _PREAMBLE_RE.sub("", text)
text = _POSTMATTER_RE.sub("", text)
text = _LINE_COMMENT_RE.sub("", text)
# Run the drop-env pass twice to handle nested same-type drops
# (e.g., a `tabular` inside a `figure`).
for _ in range(2):
text = _DROP_ENV_RE.sub("", text)
text = _DROP_BRACE_RE.sub("", text)
text = _DROP_ZEROARG_RE.sub("", text)
text = _KEEP_ARG_RE.sub(r"\2", text)
for pat, sub in _MACRO_SUB_PATTERNS:
text = pat.sub(sub, text)
# Drop any residual single-token commands (catch-all for unknown
# cleanup-safe commands).
text = re.sub(r"\\[A-Za-z]+\*?", "", text)
# Drop residual {...} braces that lost their command.
text = re.sub(r"[{}]+", "", text)
# Whitespace normalize
text = _MULTI_SPACE_RE.sub(" ", text)
text = _MULTI_NEWLINE_RE.sub("\n\n", text)
return text.strip()
class TextbookTexSource(Source):
"""Fetch + strip a Project-Gutenberg-style LaTeX source URL into a
single Document.
One URL = one Document. Use a manifest entry's ``tex_url`` field
or pass URLs directly. Idempotent at the database layer because
the strip output is deterministic (same TeX same prose same
document_root).
"""
source_type = "textbook_tex"
def __init__(self, urls: list[str] | str, *, timeout: float = 60.0):
if isinstance(urls, str):
urls = [urls]
self.urls = list(urls)
self.timeout = timeout
def iter_documents(self) -> Iterator[Document]:
with httpx.Client(
headers={"User-Agent": USER_AGENT},
timeout=self.timeout,
follow_redirects=True,
) as client:
for url in self.urls:
try:
resp = client.get(url)
resp.raise_for_status()
except httpx.HTTPError:
continue
tex = resp.text
prose = strip_tex(tex)
if not prose or len(prose) < 200:
# Stripped output too thin to be useful — likely
# an error page or a non-TeX response.
continue
title = _extract_title(tex) or url
yield Document(
uri=str(resp.url),
content=prose,
source_type=self.source_type,
title=title,
edges=[],
extra={"tex_url": url, "normalize_version": NORMALIZE_VERSION},
)
@classmethod
def from_file(cls, path: str | Path, **kwargs) -> "TextbookTexSource":
urls = [
line.strip()
for line in Path(path).read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
]
return cls(urls, **kwargs)
_TITLE_RE = re.compile(r"\\title\{([^{}]+)\}")
_AUTHOR_RE = re.compile(r"\\author\{([^{}]+)\}")
def _extract_title(tex: str) -> str:
"""Pull the title from \\title{...} preamble (best-effort)."""
m = _TITLE_RE.search(tex)
if not m:
return ""
return m.group(1).strip()

View file

@ -196,19 +196,40 @@ Coverage by g4 pillar (per the claim-pack source #000029):
| Pillar | Domain | Status | Source |
|---|---|---|---|
| I | Logic | ✓ landed | Levin (CC-BY-SA), Aristotle Prior (PD), Aristotle Posterior (PD) |
| I | Logic | ✓ landed | Levin (CC-BY-SA), Aristotle Prior (PD), Aristotle Posterior (PD), Boole (PD, PG TeX) |
| II | Set Theory | ✓ landed | Levin (CC-BY-SA) — covers ZF basics |
| III | Arithmetic (Peano) | ✓ landed | Levin (CC-BY-SA) — covers Peano basics |
| IV | Geometry | ✗ pending | Hilbert PG #17384 — PDF/TeX only, awaits TeX-source |
| IV | Geometry | ✓ landed | Hilbert (PD, PG TeX) |
| V | Probability | ✗ pending | Kolmogorov 1933 license analysis pending |
| VI | Classical Physics | ✓ landed | Newton Principia Motte (PD, Wikisource) |
| VII | Combinatorics | ✓ landed | Bogart (GFDL), Keller-Trotter (CC-BY-SA, slow), Levin |
| IX | λ-Calculus | ✗ pending | Church 1936 + Turing 1936 papers; need separate ingest path |
**5 of 7 pillars** have surface coverage; remaining gaps are
explicit license (V) or format (IV PDF/TeX, IX paper-level)
issues, each documented in the corresponding manifest entry's
`notes` field.
**6 of 7 pillars** have surface coverage. Remaining gaps:
- **Pillar V (Probability)** — Kolmogorov *Foundations of the Theory
of Probability* license analysis pending (German original PD-by-
age in EU; US copyright restored via URAA through 2058).
- **Pillar IX (λ-Calculus)** — Church 1936 + Turing 1936 papers
rather than full books; awaits a paper-level ingest helper.
## TeX-source ingest path
Two PD textbooks ship from Project Gutenberg as LaTeX source only
(no clean HTML edition):
```bash
make textbook-hilbert # PG #17384 — Foundations of Geometry
make textbook-boole # PG #15114 — Laws of Thought
make textbooks-tex # all manifest entries with a tex_url field
```
These use `arborist ingest --source textbook_tex` which fetches
the `.tex` URL, runs a focused PG-aware LaTeX-strip pipeline
(drops preamble + comments + `tabular`/`figure` envs;
keeps argument of `\textbf` / `\emph` / `\section` etc.;
substitutes `\to` → →, `\neg` → ¬, `\forall` → ∀, etc.), and
produces plain prose for the standard 512-token chunker.
## See also

View file

@ -157,6 +157,27 @@ def cmd_ids(stream: TextIO) -> int:
return 0
def cmd_tex_targets(stream: TextIO) -> int:
"""Emit one tab-separated `tex_url\\tid` row per entry that
declares a `tex_url`. Used by `make textbooks-tex` to pull PG
LaTeX-source textbooks (e.g. Hilbert #17384, Boole #15114) into
per-book shards via `arborist ingest --source textbook_tex`.
"""
for entry in iter_entries(stream):
# tex_url path is exempt from the redistribution allow-list
# because PG TeX sources for pre-1929 works are PD by age;
# the manifest entry's `license: "PD"` field carries the
# check anyway. Validate license fields nonetheless.
for k in _REQUIRED_LICENSE_KEYS:
if k not in entry or not entry[k]:
continue
tex_url = entry.get("tex_url")
if not tex_url:
continue
print(f"{tex_url}\t{entry.get('id', '?')}")
return 0
def cmd_lookup(stream: TextIO) -> int:
"""Read entry id from sys.argv[2]; emit tab-separated
`id\\tcrawl_url\\tdepth\\tmax\\tlicense\\tdomain` for the matching
@ -191,6 +212,7 @@ _COMMANDS = {
"urls": cmd_urls,
"summary": cmd_summary,
"crawl-targets": cmd_crawl_targets,
"tex-targets": cmd_tex_targets,
"ids": cmd_ids,
"lookup": cmd_lookup,
}

215
tests/test_textbook_tex.py Normal file
View file

@ -0,0 +1,215 @@
"""Tests for the TextbookTexSource (#000031 §5).
Pure unit tests over the strip_tex pipeline. Network ingest is
exercised via `make textbook-hilbert` / `make textbook-boole`; this
file stays offline.
"""
from __future__ import annotations
import pytest
from arborist.sources.textbook_tex import strip_tex
# --- preamble + postmatter stripping ---------------------------------
def test_strips_preamble_and_postmatter():
tex = r"""
% comment line
\documentclass{book}
\usepackage{amsmath}
\title{Test Book}
\begin{document}
This is the body.
\end{document}
% trailing junk
"""
out = strip_tex(tex)
assert "documentclass" not in out
assert "amsmath" not in out
assert "trailing junk" not in out
assert "This is the body." in out
def test_drops_line_comments():
tex = r"""
\begin{document}
First sentence. % this is a comment
Second sentence.
\end{document}
"""
out = strip_tex(tex)
assert "comment" not in out.lower()
assert "First sentence." in out
assert "Second sentence." in out
# --- environment dropping --------------------------------------------
def test_drops_tabular_environment():
tex = r"""
\begin{document}
Before table.
\begin{tabular}{lr}
foo & bar \\
\end{tabular}
After table.
\end{document}
"""
out = strip_tex(tex)
assert "Before table." in out
assert "After table." in out
assert "foo" not in out
assert "tabular" not in out
def test_drops_figure_environment():
tex = r"""
\begin{document}
Body 1.
\begin{figure}
\includegraphics{img.png}
\caption{Caption text}
\end{figure}
Body 2.
\end{document}
"""
out = strip_tex(tex)
assert "Body 1." in out
assert "Body 2." in out
assert "Caption text" not in out
# --- single-arg command stripping ------------------------------------
def test_keeps_arg_for_emphasis_commands():
tex = r"""
\begin{document}
This is \textbf{bold} and \emph{italic} text.
\end{document}
"""
out = strip_tex(tex)
assert "bold" in out
assert "italic" in out
assert "textbf" not in out
assert "emph" not in out
def test_keeps_arg_for_section_commands():
tex = r"""
\begin{document}
\section{Introduction}
\subsection{Background}
Body text.
\end{document}
"""
out = strip_tex(tex)
assert "Introduction" in out
assert "Background" in out
assert "Body text." in out
def test_keeps_pg_custom_commands():
"""PG uses \\rfa for roman fixed all-caps section headings."""
tex = r"""
\begin{document}
\rfa{CONTENTS}
Body.
\end{document}
"""
out = strip_tex(tex)
assert "CONTENTS" in out
assert "rfa" not in out
# --- macro substitution ----------------------------------------------
@pytest.mark.parametrize(
"tex_in,expected",
[
(r"\S 5", "§ 5"),
(r"A \to B", "A → B"),
(r"\neg P", "¬ P"),
(r"P \lor Q", "P Q"),
(r"P \land Q", "P ∧ Q"),
(r"\forall x", "∀ x"),
(r"\exists y", "∃ y"),
(r"\alpha + \beta", "α + β"),
(r"\dots", ""),
(r"x \in S", "x ∈ S"),
],
)
def test_macro_substitutions(tex_in, expected):
tex = r"\begin{document}" + "\n" + tex_in + "\n" + r"\end{document}"
out = strip_tex(tex)
assert expected in out
# --- structural commands dropped -------------------------------------
def test_drops_structural_commands():
tex = r"""
\begin{document}
\noindent Body. \bigskip
\thispagestyle{empty}
\setcounter{page}{1}
\label{intro}
\index{topic}
More body.
\end{document}
"""
out = strip_tex(tex)
assert "Body." in out
assert "More body." in out
assert "noindent" not in out
assert "thispagestyle" not in out
assert "setcounter" not in out
assert "label" not in out
assert "index" not in out
# --- whitespace cleanup ----------------------------------------------
def test_collapses_multi_blank_lines():
tex = r"""
\begin{document}
First.
Second.
Third.
\end{document}
"""
out = strip_tex(tex)
# No more than 2 consecutive newlines (= 1 blank line)
assert "\n\n\n" not in out
# --- end-to-end stability --------------------------------------------
def test_idempotent_on_already_stripped_text():
"""Plain ASCII text without any LaTeX should pass through with
only whitespace normalization."""
tex = r"""
\begin{document}
This is plain text with no LaTeX commands at all.
Multiple sentences. No special characters.
\end{document}
"""
out = strip_tex(tex)
assert "This is plain text" in out
# Round-trip: stripping again should produce the same output.
out2 = strip_tex(r"\begin{document}" + out + r"\end{document}")
assert out2 == out