lattuce-latex2md 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. lattuce_latex2md-0.2.0/PKG-INFO +142 -0
  2. lattuce_latex2md-0.2.0/README.md +125 -0
  3. lattuce_latex2md-0.2.0/pyproject.toml +37 -0
  4. lattuce_latex2md-0.2.0/setup.cfg +4 -0
  5. lattuce_latex2md-0.2.0/src/latex2md/__init__.py +7 -0
  6. lattuce_latex2md-0.2.0/src/latex2md/__main__.py +3 -0
  7. lattuce_latex2md-0.2.0/src/latex2md/cli.py +79 -0
  8. lattuce_latex2md-0.2.0/src/latex2md/comments.py +25 -0
  9. lattuce_latex2md-0.2.0/src/latex2md/includes.py +113 -0
  10. lattuce_latex2md-0.2.0/src/latex2md/latex_to_md.lua +168 -0
  11. lattuce_latex2md-0.2.0/src/latex2md/latex_to_md.py +132 -0
  12. lattuce_latex2md-0.2.0/src/latex2md/md_to_latex.py +78 -0
  13. lattuce_latex2md-0.2.0/src/latex2md/mhchem.py +83 -0
  14. lattuce_latex2md-0.2.0/src/latex2md/pandoc.py +75 -0
  15. lattuce_latex2md-0.2.0/src/lattuce_latex2md.egg-info/PKG-INFO +142 -0
  16. lattuce_latex2md-0.2.0/src/lattuce_latex2md.egg-info/SOURCES.txt +24 -0
  17. lattuce_latex2md-0.2.0/src/lattuce_latex2md.egg-info/dependency_links.txt +1 -0
  18. lattuce_latex2md-0.2.0/src/lattuce_latex2md.egg-info/entry_points.txt +2 -0
  19. lattuce_latex2md-0.2.0/src/lattuce_latex2md.egg-info/requires.txt +4 -0
  20. lattuce_latex2md-0.2.0/src/lattuce_latex2md.egg-info/top_level.txt +1 -0
  21. lattuce_latex2md-0.2.0/tests/test_cli.py +75 -0
  22. lattuce_latex2md-0.2.0/tests/test_includes.py +157 -0
  23. lattuce_latex2md-0.2.0/tests/test_latex_to_md.py +56 -0
  24. lattuce_latex2md-0.2.0/tests/test_md_to_latex.py +78 -0
  25. lattuce_latex2md-0.2.0/tests/test_mhchem.py +22 -0
  26. lattuce_latex2md-0.2.0/tests/test_roundtrip.py +33 -0
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: lattuce-latex2md
3
+ Version: 0.2.0
4
+ Summary: Markdown <-> LaTeX conversion built on pandoc, with math, mhchem and citation clean-up.
5
+ Project-URL: Source, https://github.com/lattuce-ai/latex2md
6
+ Project-URL: Issues, https://github.com/lattuce-ai/latex2md/issues
7
+ Keywords: markdown,latex,pandoc,converter,arxiv
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Text Processing :: Markup :: LaTeX
11
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8; extra == "dev"
16
+ Requires-Dist: build>=1.2; extra == "dev"
17
+
18
+ # latex2md
19
+
20
+ Convert **Markdown → LaTeX** and **LaTeX → Markdown**, as a Python library or a CLI.
21
+
22
+ It runs [pandoc](https://pandoc.org) and cleans up the output on both sides, so you get
23
+ plain, readable Markdown and LaTeX fragments you can paste into an existing document.
24
+
25
+ ## Install
26
+
27
+ You need **pandoc** 3.0 or later (CI tests 3.1 and 3.6) on your `PATH`:
28
+
29
+ ```bash
30
+ sudo apt-get install pandoc
31
+ ```
32
+
33
+ ```bash
34
+ pip install lattuce-latex2md
35
+ ```
36
+
37
+ The distribution is called `lattuce-latex2md` (the name `latex2md` is taken on PyPI
38
+ by an unrelated project); the import name is `latex2md`. From a checkout:
39
+ `pip install -e .`.
40
+
41
+ If pandoc is somewhere else, set `LATEX2MD_PANDOC=/path/to/pandoc`.
42
+
43
+ ## CLI
44
+
45
+ ```bash
46
+ latex2md to-latex notes.md -o notes.tex
47
+ ```
48
+
49
+ ```bash
50
+ latex2md to-md paper.tex -o paper.md
51
+ ```
52
+
53
+ ```bash
54
+ latex2md auto paper.tex
55
+ ```
56
+
57
+ - With no input file, or `-`, it reads stdin. With no `-o`, it writes to stdout.
58
+ - `auto` picks the direction from the extension (`.md/.markdown` or `.tex/.latex/.ltx`).
59
+ - `--standalone` (for `to-latex` and `auto`) writes a complete document with a preamble.
60
+ - `--base-dir DIR` (for `to-md` and `auto`) is the folder `\input`/`\include` may read from.
61
+ It defaults to the input file's folder (or the current folder for stdin).
62
+
63
+ ## Python
64
+
65
+ ```python
66
+ from latex2md import md_to_latex, latex_to_md, ConversionError
67
+
68
+ tex = md_to_latex("# Title\n\nEnergy is $E = mc^2$ [@einstein1905].")
69
+ md = latex_to_md(r"\section{Title} Energy is $E = mc^2$ \cite{einstein1905}.")
70
+ full = md_to_latex(markdown_text, standalone=True)
71
+
72
+ # Multi-file projects (e.g. an unpacked arXiv source archive):
73
+ md = latex_file_to_md("paper/main.tex") # YAML front matter
74
+ md = latex_file_to_md("paper/main.tex", front_matter=False) # abstract as a section
75
+ md = latex_to_md(tex_text, base_dir="paper/") # text + project folder
76
+ ```
77
+
78
+ All errors (pandoc missing, failing or timing out) raise `ConversionError`; its
79
+ `.stderr` holds pandoc's message.
80
+
81
+ ## What it handles
82
+
83
+ | | Markdown → LaTeX | LaTeX → Markdown |
84
+ |---|---|---|
85
+ | Headings | `\section{}` + `\label{}` | `#`, `##`, … (labels dropped) |
86
+ | Math | `\( \)` and `\[ \]` | `$…$` and `$$…$$` |
87
+ | Chemistry (`\ce{}`) | kept; `--standalone` loads `mhchem` | kept inside math |
88
+ | Citations | `[@key]` → `\cite{key}` | `\cite{key}` → `[@key]` |
89
+ | Lists | `itemize` / `enumerate` | tight `-` / `1.` lists |
90
+ | Tables | `longtable` (needs `longtable` + `booktabs`) | pipe tables; first row becomes header |
91
+ | Code | `verbatim` | fenced blocks |
92
+ | Comments | — | `%` comments removed; `\%`, `\verb` and verbatim kept |
93
+ | Multi-file projects | — | `\input`, `\include`, `\subfile`, `\import`, `\lstinputlisting` inlined from the project folder |
94
+ | Title, authors, abstract | — | YAML front matter, or an "Abstract" section with `front_matter=False` |
95
+ | Macros (`\newcommand`) | — | expanded |
96
+ | Floats | — | `figure` → image with caption as alt text; `table` → "**Table:** caption" + pipe table |
97
+ | Cross-references | — | `\ref` → its number as text (no dead `#anchor` links); `\eqref` → `(label)` |
98
+ | Bibliography | — | `thebibliography` → "References" list; a sibling `.bbl` fills `\bibliography{}` |
99
+
100
+ Fragment output (the default) avoids macros that only pandoc's template defines
101
+ (`\tightlist`, `Shaded`, `\hypertarget`), so it compiles in an ordinary preamble.
102
+
103
+ ## Safety
104
+
105
+ LaTeX can name any file on disk (`\input{/etc/passwd}`), so latex2md never lets
106
+ pandoc read files itself: pandoc always runs with `--sandbox`, and latex2md
107
+ inlines includes only when they resolve inside the base folder. Absolute paths,
108
+ `..` escapes, symlinks pointing outside, cycles, nesting deeper than 16 levels and
109
+ more than 20 MB of included files are skipped. Without a base folder, include
110
+ commands are removed. This makes it safe to run on untrusted uploads.
111
+
112
+ ## Limitations
113
+
114
+ - Custom macros and unusual packages in LaTeX are only as good as pandoc's LaTeX reader;
115
+ unknown commands are dropped.
116
+ - Equations are not numbered in Markdown, so `\eqref` shows the label, e.g. `(eq:loss)`.
117
+ - Bibliographies from `.bib` files need the compiled `.bbl`; citations stay as `[@key]`.
118
+ - Figures with `\includegraphics` keep the path; image files are not copied.
119
+
120
+ ## Settings
121
+
122
+ | Variable | Default | Meaning |
123
+ |---|---|---|
124
+ | `LATEX2MD_PANDOC` | `pandoc` on `PATH` | pandoc binary to run |
125
+ | `LATEX2MD_TIMEOUT` | `60` | seconds before a conversion is stopped |
126
+
127
+ ## Development
128
+
129
+ ```bash
130
+ pip install -e '.[dev]'
131
+ ```
132
+
133
+ ```bash
134
+ pytest
135
+ ```
136
+
137
+ ## Releasing
138
+
139
+ Bump `version` in `pyproject.toml` and `__version__` in `src/latex2md/__init__.py`,
140
+ then push a matching tag (`v0.2.0`). The `release` workflow tests, builds and
141
+ publishes to PyPI with trusted publishing (setup steps are in
142
+ `.github/workflows/release.yml`).
@@ -0,0 +1,125 @@
1
+ # latex2md
2
+
3
+ Convert **Markdown → LaTeX** and **LaTeX → Markdown**, as a Python library or a CLI.
4
+
5
+ It runs [pandoc](https://pandoc.org) and cleans up the output on both sides, so you get
6
+ plain, readable Markdown and LaTeX fragments you can paste into an existing document.
7
+
8
+ ## Install
9
+
10
+ You need **pandoc** 3.0 or later (CI tests 3.1 and 3.6) on your `PATH`:
11
+
12
+ ```bash
13
+ sudo apt-get install pandoc
14
+ ```
15
+
16
+ ```bash
17
+ pip install lattuce-latex2md
18
+ ```
19
+
20
+ The distribution is called `lattuce-latex2md` (the name `latex2md` is taken on PyPI
21
+ by an unrelated project); the import name is `latex2md`. From a checkout:
22
+ `pip install -e .`.
23
+
24
+ If pandoc is somewhere else, set `LATEX2MD_PANDOC=/path/to/pandoc`.
25
+
26
+ ## CLI
27
+
28
+ ```bash
29
+ latex2md to-latex notes.md -o notes.tex
30
+ ```
31
+
32
+ ```bash
33
+ latex2md to-md paper.tex -o paper.md
34
+ ```
35
+
36
+ ```bash
37
+ latex2md auto paper.tex
38
+ ```
39
+
40
+ - With no input file, or `-`, it reads stdin. With no `-o`, it writes to stdout.
41
+ - `auto` picks the direction from the extension (`.md/.markdown` or `.tex/.latex/.ltx`).
42
+ - `--standalone` (for `to-latex` and `auto`) writes a complete document with a preamble.
43
+ - `--base-dir DIR` (for `to-md` and `auto`) is the folder `\input`/`\include` may read from.
44
+ It defaults to the input file's folder (or the current folder for stdin).
45
+
46
+ ## Python
47
+
48
+ ```python
49
+ from latex2md import md_to_latex, latex_to_md, ConversionError
50
+
51
+ tex = md_to_latex("# Title\n\nEnergy is $E = mc^2$ [@einstein1905].")
52
+ md = latex_to_md(r"\section{Title} Energy is $E = mc^2$ \cite{einstein1905}.")
53
+ full = md_to_latex(markdown_text, standalone=True)
54
+
55
+ # Multi-file projects (e.g. an unpacked arXiv source archive):
56
+ md = latex_file_to_md("paper/main.tex") # YAML front matter
57
+ md = latex_file_to_md("paper/main.tex", front_matter=False) # abstract as a section
58
+ md = latex_to_md(tex_text, base_dir="paper/") # text + project folder
59
+ ```
60
+
61
+ All errors (pandoc missing, failing or timing out) raise `ConversionError`; its
62
+ `.stderr` holds pandoc's message.
63
+
64
+ ## What it handles
65
+
66
+ | | Markdown → LaTeX | LaTeX → Markdown |
67
+ |---|---|---|
68
+ | Headings | `\section{}` + `\label{}` | `#`, `##`, … (labels dropped) |
69
+ | Math | `\( \)` and `\[ \]` | `$…$` and `$$…$$` |
70
+ | Chemistry (`\ce{}`) | kept; `--standalone` loads `mhchem` | kept inside math |
71
+ | Citations | `[@key]` → `\cite{key}` | `\cite{key}` → `[@key]` |
72
+ | Lists | `itemize` / `enumerate` | tight `-` / `1.` lists |
73
+ | Tables | `longtable` (needs `longtable` + `booktabs`) | pipe tables; first row becomes header |
74
+ | Code | `verbatim` | fenced blocks |
75
+ | Comments | — | `%` comments removed; `\%`, `\verb` and verbatim kept |
76
+ | Multi-file projects | — | `\input`, `\include`, `\subfile`, `\import`, `\lstinputlisting` inlined from the project folder |
77
+ | Title, authors, abstract | — | YAML front matter, or an "Abstract" section with `front_matter=False` |
78
+ | Macros (`\newcommand`) | — | expanded |
79
+ | Floats | — | `figure` → image with caption as alt text; `table` → "**Table:** caption" + pipe table |
80
+ | Cross-references | — | `\ref` → its number as text (no dead `#anchor` links); `\eqref` → `(label)` |
81
+ | Bibliography | — | `thebibliography` → "References" list; a sibling `.bbl` fills `\bibliography{}` |
82
+
83
+ Fragment output (the default) avoids macros that only pandoc's template defines
84
+ (`\tightlist`, `Shaded`, `\hypertarget`), so it compiles in an ordinary preamble.
85
+
86
+ ## Safety
87
+
88
+ LaTeX can name any file on disk (`\input{/etc/passwd}`), so latex2md never lets
89
+ pandoc read files itself: pandoc always runs with `--sandbox`, and latex2md
90
+ inlines includes only when they resolve inside the base folder. Absolute paths,
91
+ `..` escapes, symlinks pointing outside, cycles, nesting deeper than 16 levels and
92
+ more than 20 MB of included files are skipped. Without a base folder, include
93
+ commands are removed. This makes it safe to run on untrusted uploads.
94
+
95
+ ## Limitations
96
+
97
+ - Custom macros and unusual packages in LaTeX are only as good as pandoc's LaTeX reader;
98
+ unknown commands are dropped.
99
+ - Equations are not numbered in Markdown, so `\eqref` shows the label, e.g. `(eq:loss)`.
100
+ - Bibliographies from `.bib` files need the compiled `.bbl`; citations stay as `[@key]`.
101
+ - Figures with `\includegraphics` keep the path; image files are not copied.
102
+
103
+ ## Settings
104
+
105
+ | Variable | Default | Meaning |
106
+ |---|---|---|
107
+ | `LATEX2MD_PANDOC` | `pandoc` on `PATH` | pandoc binary to run |
108
+ | `LATEX2MD_TIMEOUT` | `60` | seconds before a conversion is stopped |
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ pip install -e '.[dev]'
114
+ ```
115
+
116
+ ```bash
117
+ pytest
118
+ ```
119
+
120
+ ## Releasing
121
+
122
+ Bump `version` in `pyproject.toml` and `__version__` in `src/latex2md/__init__.py`,
123
+ then push a matching tag (`v0.2.0`). The `release` workflow tests, builds and
124
+ publishes to PyPI with trusted publishing (setup steps are in
125
+ `.github/workflows/release.yml`).
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "lattuce-latex2md"
7
+ version = "0.2.0"
8
+ description = "Markdown <-> LaTeX conversion built on pandoc, with math, mhchem and citation clean-up."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = []
12
+ keywords = ["markdown", "latex", "pandoc", "converter", "arxiv"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Operating System :: OS Independent",
16
+ "Topic :: Text Processing :: Markup :: LaTeX",
17
+ "Topic :: Text Processing :: Markup :: Markdown",
18
+ ]
19
+
20
+ [project.urls]
21
+ Source = "https://github.com/lattuce-ai/latex2md"
22
+ Issues = "https://github.com/lattuce-ai/latex2md/issues"
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest>=8", "build>=1.2"]
26
+
27
+ [project.scripts]
28
+ latex2md = "latex2md.cli:main"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.setuptools.package-data]
34
+ latex2md = ["*.lua"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """Markdown <-> LaTeX conversion built on pandoc."""
2
+ from latex2md.latex_to_md import latex_file_to_md, latex_to_md
3
+ from latex2md.md_to_latex import md_to_latex
4
+ from latex2md.pandoc import ConversionError, pandoc_available
5
+
6
+ __all__ = ["ConversionError", "latex_file_to_md", "latex_to_md", "md_to_latex", "pandoc_available"]
7
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from latex2md.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,79 @@
1
+ """Command-line interface: ``latex2md to-latex|to-md|auto``."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from latex2md.latex_to_md import latex_file_to_md, latex_to_md
9
+ from latex2md.md_to_latex import md_to_latex
10
+ from latex2md.pandoc import ConversionError
11
+
12
+ MARKDOWN_SUFFIXES = {".md", ".markdown", ".mdown"}
13
+ LATEX_SUFFIXES = {".tex", ".latex", ".ltx"}
14
+
15
+
16
+ def _read(path: str) -> str:
17
+ if path == "-":
18
+ return sys.stdin.read()
19
+ return Path(path).read_text(encoding="utf-8")
20
+
21
+
22
+ def _write(path: str | None, text: str) -> None:
23
+ if not path or path == "-":
24
+ sys.stdout.write(text)
25
+ else:
26
+ Path(path).write_text(text, encoding="utf-8")
27
+
28
+
29
+ def _direction(command: str, path: str) -> str:
30
+ if command != "auto":
31
+ return command
32
+ suffix = Path(path).suffix.lower()
33
+ if suffix in MARKDOWN_SUFFIXES:
34
+ return "to-latex"
35
+ if suffix in LATEX_SUFFIXES:
36
+ return "to-md"
37
+ raise ConversionError(f"cannot tell the input format from {path!r}; use to-latex or to-md")
38
+
39
+
40
+ def build_parser() -> argparse.ArgumentParser:
41
+ parser = argparse.ArgumentParser(prog="latex2md", description="Convert between Markdown and LaTeX.")
42
+ sub = parser.add_subparsers(dest="command", required=True)
43
+ for name, help_text in (
44
+ ("to-latex", "Markdown -> LaTeX"),
45
+ ("to-md", "LaTeX -> Markdown"),
46
+ ("auto", "pick the direction from the input file extension"),
47
+ ):
48
+ cmd = sub.add_parser(name, help=help_text)
49
+ cmd.add_argument("input", nargs="?", default="-", help="input file, or - for stdin (default)")
50
+ cmd.add_argument("-o", "--output", help="output file (default: stdout)")
51
+ if name != "to-latex":
52
+ cmd.add_argument(
53
+ "--base-dir",
54
+ help="folder that \\input/\\include may read from (default: the input file's folder, or cwd for stdin)",
55
+ )
56
+ if name != "to-md":
57
+ cmd.add_argument("--standalone", action="store_true", help="emit a complete LaTeX document")
58
+ return parser
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ args = build_parser().parse_args(argv)
63
+ try:
64
+ direction = _direction(args.command, args.input)
65
+ if direction == "to-latex":
66
+ result = md_to_latex(_read(args.input), standalone=getattr(args, "standalone", False))
67
+ elif args.input == "-":
68
+ result = latex_to_md(_read(args.input), base_dir=args.base_dir or Path.cwd())
69
+ else:
70
+ result = latex_file_to_md(args.input, base_dir=args.base_dir)
71
+ _write(args.output, result)
72
+ except (ConversionError, OSError) as exc:
73
+ print(f"latex2md: {exc}", file=sys.stderr)
74
+ return 1
75
+ return 0
76
+
77
+
78
+ if __name__ == "__main__": # pragma: no cover
79
+ raise SystemExit(main())
@@ -0,0 +1,25 @@
1
+ """LaTeX comment stripping that leaves verbatim content alone."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ # A % starts a comment unless it is escaped (\%).
7
+ _COMMENT_RE = re.compile(r"(?<!\\)%.*$", re.M)
8
+ _COMMENT_ONLY_LINE_RE = re.compile(r"^[ \t]*(?<!\\)%.*\n", re.M)
9
+ _VERBATIM_RE = re.compile(r"\\begin\{(verbatim\*?|lstlisting|minted|comment)\}.*?\\end\{\1\}", re.S)
10
+ _VERB_INLINE_RE = re.compile(r"\\verb\*?([^a-zA-Z\s*]).*?\1")
11
+
12
+
13
+ def strip_comments(latex: str) -> str:
14
+ """Remove % comments, leaving verbatim environments and \\verb untouched."""
15
+ protected: list[str] = []
16
+
17
+ def protect(match: re.Match[str]) -> str:
18
+ protected.append(match.group(0))
19
+ return f"\x00{len(protected) - 1}\x00"
20
+
21
+ text = _VERBATIM_RE.sub(protect, latex)
22
+ text = _VERB_INLINE_RE.sub(protect, text)
23
+ text = _COMMENT_ONLY_LINE_RE.sub("", text)
24
+ text = _COMMENT_RE.sub("", text)
25
+ return re.sub(r"\x00(\d+)\x00", lambda m: protected[int(m.group(1))], text)
@@ -0,0 +1,113 @@
1
+ """Inline \\input / \\include files before pandoc sees the document.
2
+
3
+ pandoc always runs with ``--sandbox`` (see :mod:`latex2md.pandoc`), so it cannot
4
+ read files on its own. This module resolves include commands itself, but only
5
+ for files inside ``base_dir``: absolute paths, ``..`` escapes and symlinks that
6
+ point outside are dropped. That makes it safe to convert untrusted LaTeX, such
7
+ as arXiv source archives.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from latex2md.comments import strip_comments
15
+
16
+ MAX_INCLUDE_DEPTH = 16
17
+ MAX_INCLUDED_BYTES = 20 * 1024 * 1024
18
+
19
+ _GROUP = r"\{\s*([^{}]+?)\s*\}"
20
+ # \input{file}, \include{file}, \subfile{file}, \input file
21
+ _INPUT_RE = re.compile(r"\\(input|include|subfile)\s*(?:" + _GROUP + r"|\s+([^\s{}\\]+))")
22
+ # \import{dir}{file}, \subimport{dir}{file}, starred variants
23
+ _IMPORT_RE = re.compile(r"\\(?:sub)?(?:import|inputfrom|includefrom)\*?\s*" + _GROUP + r"\s*" + _GROUP)
24
+ # \lstinputlisting[opts]{file}, \verbatiminput{file}
25
+ _LISTING_RE = re.compile(r"\\(lstinputlisting|verbatiminput)\s*(?:\[[^\]]*\])?\s*" + _GROUP)
26
+
27
+
28
+ class _Budget:
29
+ def __init__(self) -> None:
30
+ self.remaining = MAX_INCLUDED_BYTES
31
+
32
+
33
+ def _resolve(base_dir: Path, current_dir: Path, name: str, *, add_tex: bool) -> Path | None:
34
+ name = name.strip().strip('"')
35
+ if not name or Path(name).is_absolute():
36
+ return None
37
+ candidates = [current_dir / name, base_dir / name]
38
+ if add_tex and not name.endswith(".tex"):
39
+ candidates = [path for c in candidates for path in (c.with_name(c.name + ".tex"), c)]
40
+ root = base_dir.resolve()
41
+ for candidate in candidates:
42
+ try:
43
+ resolved = candidate.resolve()
44
+ except OSError:
45
+ continue
46
+ if resolved.is_relative_to(root) and resolved.is_file():
47
+ return resolved
48
+ return None
49
+
50
+
51
+ def _read(path: Path, budget: _Budget) -> str | None:
52
+ size = path.stat().st_size
53
+ if size > budget.remaining:
54
+ return None
55
+ budget.remaining -= size
56
+ return path.read_text(encoding="utf-8", errors="replace")
57
+
58
+
59
+ def _expand(text: str, *, base_dir: Path, current_dir: Path, stack: tuple[Path, ...], budget: _Budget) -> str:
60
+ def include(path: Path | None) -> str:
61
+ if path is None or path in stack or len(stack) >= MAX_INCLUDE_DEPTH:
62
+ return ""
63
+ content = _read(path, budget)
64
+ if content is None:
65
+ return ""
66
+ body = _subfile_body(strip_comments(content))
67
+ expanded = _expand(body, base_dir=base_dir, current_dir=path.parent, stack=(*stack, path), budget=budget)
68
+ return f"\n{expanded}\n"
69
+
70
+ def replace_input(match: re.Match[str]) -> str:
71
+ name = match.group(2) or match.group(3) or ""
72
+ return include(_resolve(base_dir, current_dir, name, add_tex=True))
73
+
74
+ def replace_import(match: re.Match[str]) -> str:
75
+ directory, name = match.group(1), match.group(2)
76
+ return include(_resolve(base_dir, current_dir, str(Path(directory) / name), add_tex=True))
77
+
78
+ def replace_listing(match: re.Match[str]) -> str:
79
+ path = _resolve(base_dir, current_dir, match.group(2), add_tex=False)
80
+ content = _read(path, budget) if path else None
81
+ if content is None:
82
+ return ""
83
+ return "\n\\begin{verbatim}\n" + content.rstrip("\n").replace("\\end{verbatim}", "") + "\n\\end{verbatim}\n"
84
+
85
+ text = _LISTING_RE.sub(replace_listing, text)
86
+ text = _IMPORT_RE.sub(replace_import, text)
87
+ return _INPUT_RE.sub(replace_input, text)
88
+
89
+
90
+ _DOCUMENT_BODY_RE = re.compile(r"\\begin\{document\}(.*)\\end\{document\}", re.S)
91
+
92
+
93
+ def _subfile_body(text: str) -> str:
94
+ """A \\subfile is a full document; keep only its body."""
95
+ if "\\documentclass" in text:
96
+ match = _DOCUMENT_BODY_RE.search(text)
97
+ if match:
98
+ return match.group(1)
99
+ return text
100
+
101
+
102
+ def expand_includes(latex: str, base_dir: str | Path | None) -> str:
103
+ """Inline include commands resolved under ``base_dir``; drop the rest.
104
+
105
+ ``latex`` must already have its comments stripped. With no ``base_dir``
106
+ every include command is removed.
107
+ """
108
+ if base_dir is None:
109
+ text = _LISTING_RE.sub("", latex)
110
+ text = _IMPORT_RE.sub("", text)
111
+ return _INPUT_RE.sub("", text)
112
+ root = Path(base_dir).resolve()
113
+ return _expand(latex, base_dir=root, current_dir=root, stack=(), budget=_Budget())