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.
- lattuce_latex2md-0.2.0/PKG-INFO +142 -0
- lattuce_latex2md-0.2.0/README.md +125 -0
- lattuce_latex2md-0.2.0/pyproject.toml +37 -0
- lattuce_latex2md-0.2.0/setup.cfg +4 -0
- lattuce_latex2md-0.2.0/src/latex2md/__init__.py +7 -0
- lattuce_latex2md-0.2.0/src/latex2md/__main__.py +3 -0
- lattuce_latex2md-0.2.0/src/latex2md/cli.py +79 -0
- lattuce_latex2md-0.2.0/src/latex2md/comments.py +25 -0
- lattuce_latex2md-0.2.0/src/latex2md/includes.py +113 -0
- lattuce_latex2md-0.2.0/src/latex2md/latex_to_md.lua +168 -0
- lattuce_latex2md-0.2.0/src/latex2md/latex_to_md.py +132 -0
- lattuce_latex2md-0.2.0/src/latex2md/md_to_latex.py +78 -0
- lattuce_latex2md-0.2.0/src/latex2md/mhchem.py +83 -0
- lattuce_latex2md-0.2.0/src/latex2md/pandoc.py +75 -0
- lattuce_latex2md-0.2.0/src/lattuce_latex2md.egg-info/PKG-INFO +142 -0
- lattuce_latex2md-0.2.0/src/lattuce_latex2md.egg-info/SOURCES.txt +24 -0
- lattuce_latex2md-0.2.0/src/lattuce_latex2md.egg-info/dependency_links.txt +1 -0
- lattuce_latex2md-0.2.0/src/lattuce_latex2md.egg-info/entry_points.txt +2 -0
- lattuce_latex2md-0.2.0/src/lattuce_latex2md.egg-info/requires.txt +4 -0
- lattuce_latex2md-0.2.0/src/lattuce_latex2md.egg-info/top_level.txt +1 -0
- lattuce_latex2md-0.2.0/tests/test_cli.py +75 -0
- lattuce_latex2md-0.2.0/tests/test_includes.py +157 -0
- lattuce_latex2md-0.2.0/tests/test_latex_to_md.py +56 -0
- lattuce_latex2md-0.2.0/tests/test_md_to_latex.py +78 -0
- lattuce_latex2md-0.2.0/tests/test_mhchem.py +22 -0
- 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,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,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())
|