litdown 0.3.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.
- litdown-0.3.0/LICENSE +21 -0
- litdown-0.3.0/PKG-INFO +186 -0
- litdown-0.3.0/README.md +168 -0
- litdown-0.3.0/litdown/__init__.py +57 -0
- litdown-0.3.0/litdown/__main__.py +27 -0
- litdown-0.3.0/litdown/common.py +161 -0
- litdown-0.3.0/litdown/elsevier.py +1292 -0
- litdown-0.3.0/litdown/jats.py +1512 -0
- litdown-0.3.0/litdown/mathml.py +1080 -0
- litdown-0.3.0/litdown/py.typed +0 -0
- litdown-0.3.0/litdown.egg-info/PKG-INFO +186 -0
- litdown-0.3.0/litdown.egg-info/SOURCES.txt +19 -0
- litdown-0.3.0/litdown.egg-info/dependency_links.txt +1 -0
- litdown-0.3.0/litdown.egg-info/entry_points.txt +2 -0
- litdown-0.3.0/litdown.egg-info/requires.txt +1 -0
- litdown-0.3.0/litdown.egg-info/top_level.txt +1 -0
- litdown-0.3.0/pyproject.toml +151 -0
- litdown-0.3.0/setup.cfg +4 -0
- litdown-0.3.0/tests/test_elsevier_articles.py +208 -0
- litdown-0.3.0/tests/test_jats_articles.py +175 -0
- litdown-0.3.0/tests/test_mml_unit.py +641 -0
litdown-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Centre for Population Genomics
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
litdown-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: litdown
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Convert scholarly full-text XML (JATS, Elsevier) to Markdown.
|
|
5
|
+
Author: Toby Sargeant
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/populationgenomics/litdown
|
|
8
|
+
Project-URL: Issues, https://github.com/populationgenomics/litdown/issues
|
|
9
|
+
Keywords: jats,elsevier,pmc,markdown,mathml,latex
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Text Processing :: Markup :: XML
|
|
12
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: defusedxml>=0.7
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# litdown
|
|
20
|
+
|
|
21
|
+
[](https://github.com/populationgenomics/litdown/actions/workflows/lint.yaml)
|
|
22
|
+
|
|
23
|
+
Convert scholarly full-text XML to Markdown with embedded LaTeX for inline and
|
|
24
|
+
display math. Two dialects are supported behind a single `convert` entry point
|
|
25
|
+
that sniffs the document root and dispatches:
|
|
26
|
+
|
|
27
|
+
- **JATS** (`<article>`) — the format PubMed Central distributes.
|
|
28
|
+
- **Elsevier** (`<full-text-retrieval-response>`) — the ScienceDirect Article
|
|
29
|
+
Retrieval API's `xocs`/`ja`/`ce` schema.
|
|
30
|
+
|
|
31
|
+
The intended consumer is downstream LLM tooling — the markdown is plain text
|
|
32
|
+
suitable for retrieval, summarisation, or analysis without round-tripping
|
|
33
|
+
through a typesetter.
|
|
34
|
+
|
|
35
|
+
## Spec target
|
|
36
|
+
|
|
37
|
+
The **JATS** dialect is implemented against the **JATS Journal Archiving and
|
|
38
|
+
Interchange Tag Set (Archiving), NISO Z39.96-2024 v1.4** — the format PMC
|
|
39
|
+
distributes. PMC upconverts older content (NLM Archiving 1.x–3.x, JATS
|
|
40
|
+
1.0–1.3) into 1.4 when serving the OA bucket, so a converter that handles 1.4
|
|
41
|
+
covers the entire PMC corpus regardless of when the article was authored.
|
|
42
|
+
|
|
43
|
+
This is **not** the Article Authoring tag set (more restrictive; intended as
|
|
44
|
+
an authoring target, not a corpus). Article-Authoring-only content is a
|
|
45
|
+
subset of Archiving content and works without code changes.
|
|
46
|
+
|
|
47
|
+
The **Elsevier** dialect targets the `ce:`/`ja:`/`xocs:` schema returned by
|
|
48
|
+
the ScienceDirect Article Retrieval API. Math is standard W3C MathML (shared
|
|
49
|
+
with the JATS math path); tables are CALS (`tgroup`/`row`/`entry`); references
|
|
50
|
+
parse the structured `sb:` (Siemens) model. An unrecognised root element
|
|
51
|
+
raises `ValueError` rather than returning an empty string, so a caller passing
|
|
52
|
+
the wrong bytes fails loudly.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install -e . # runtime
|
|
58
|
+
pip install -e '.[dev]' # runtime + pytest
|
|
59
|
+
pip install -r requirements-dev.txt && pre-commit install # contributing
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Editable install. Provides a `litdown` console script.
|
|
63
|
+
|
|
64
|
+
## Use
|
|
65
|
+
|
|
66
|
+
CLI:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
litdown article.xml > article.md
|
|
70
|
+
litdown article.xml article.md
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Library:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from litdown import convert, mml_to_tex, render_mathml
|
|
77
|
+
|
|
78
|
+
md = convert("article.xml") # JATS or Elsevier XML path → markdown
|
|
79
|
+
latex = mml_to_tex(math_element) # MathML Element → LaTeX
|
|
80
|
+
fragment = render_mathml(math_element, display=True) # → "$$...$$"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## What's in the package
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
litdown/
|
|
87
|
+
jats.py JATS XML → Markdown
|
|
88
|
+
elsevier.py Elsevier (ce:/ja:/xocs:) XML → Markdown
|
|
89
|
+
common.py dialect-neutral leaves (tag helpers, table grid, inline wraps)
|
|
90
|
+
mathml.py MathML → LaTeX
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The MathML converter is the more battle-tested piece — it has been graded
|
|
94
|
+
against the W3C MathML 3 Presentation test suite using both Pandoc and a
|
|
95
|
+
Gemini blind-grading harness. The cases that survived grading are checked
|
|
96
|
+
in under `tests/w3c_mml/` with their expected LaTeX in `tests/golden.json`;
|
|
97
|
+
the regression suite re-runs the converter over them on every test run.
|
|
98
|
+
|
|
99
|
+
## Tests and fixtures
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pytest # full suite
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Three test files:
|
|
106
|
+
|
|
107
|
+
- `tests/test_mml_unit.py` — exhaustive per-element MathML cases.
|
|
108
|
+
- `tests/test_jats_articles.py` — structural assertions over real PMC
|
|
109
|
+
articles in `tests/fixtures/<PMCID>/`, parametrised so adding a fixture
|
|
110
|
+
extends the suite automatically. Known per-fixture defects are
|
|
111
|
+
xfail-marked in a `KNOWN_BUGS` dict so the suite stays green; when a fix
|
|
112
|
+
lands the xfail flips to "unexpectedly passed" and forces the entry's
|
|
113
|
+
removal.
|
|
114
|
+
- `tests/test_elsevier_articles.py` — structural assertions over Elsevier
|
|
115
|
+
articles committed as flat `*.xml` files under `tests/fixtures/elsevier/`
|
|
116
|
+
(math not dropped, CALS tables rendered, every cross-ref/float/reference
|
|
117
|
+
anchored). Vendor only CC-BY (`by/4.0`) articles; see
|
|
118
|
+
`docs/elsevier-dialect-plan.md` for how to harvest fixtures.
|
|
119
|
+
|
|
120
|
+
### Fetching test fixtures
|
|
121
|
+
|
|
122
|
+
PMC articles are not redistributed in this repository — each article has
|
|
123
|
+
its own licence (a mix of CC-BY, CC-BY-NC variants, and others), and the
|
|
124
|
+
publisher PDFs in particular carry more restrictive terms. The fixture
|
|
125
|
+
directories are gitignored. To populate them:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
python tools/fetch_pmc.py --manifest tests/fixtures/MANIFEST.txt
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
This reads `tests/fixtures/MANIFEST.txt` (one PMCID per line), pulls each
|
|
132
|
+
article's JATS XML, publisher PDF, plain text, and referenced figure
|
|
133
|
+
assets from the public `pmc-oa-opendata` S3 bucket, and caches them under
|
|
134
|
+
`tests/fixtures/<PMCID>/`. Fetches are idempotent; re-running is cheap.
|
|
135
|
+
|
|
136
|
+
The article-fixture tests skip cleanly when no fixtures are present, so
|
|
137
|
+
`pytest` works against the MathML unit suite alone.
|
|
138
|
+
|
|
139
|
+
## tools/
|
|
140
|
+
|
|
141
|
+
Discovery and evaluation utilities — none are imported by the package or
|
|
142
|
+
needed for normal use.
|
|
143
|
+
|
|
144
|
+
| Script | Purpose |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `fetch_pmc.py` | Cache a PMCID's JATS XML, publisher PDF, plain text and figure assets into `tests/fixtures/<PMCID>/`. Default `core` mode skips supplementary materials; pass `--all` to include them. |
|
|
147
|
+
| `eval_articles.py` | Send fixture PDF + our markdown to Vertex AI Gemini and ask it to enumerate content-fidelity gaps. Findings appended to `eval_findings.jsonl`. Run ad-hoc, not in CI. Requires `LITDOWN_GCP_PROJECT` env var or `--project`. |
|
|
148
|
+
| `test_mml.py` | Run our MathML converter against the W3C test suite and against the npm `mathml-to-latex` package; produce a per-test report. |
|
|
149
|
+
| `grade_mml.py` | Blind A/B grade MathML disagreements against the W3C reference using Gemini. |
|
|
150
|
+
| `build_grading_page.py`, `build_preview_page.py` | Build self-contained HTML pages for human review of the grading runs. |
|
|
151
|
+
| `mml2tex_shim.js` | Node entry point used by `test_mml.py` to call the npm `mathml-to-latex` library. |
|
|
152
|
+
|
|
153
|
+
## The discovery loop
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
fetch_pmc.py (acquire fixture)
|
|
157
|
+
↓
|
|
158
|
+
litdown.convert
|
|
159
|
+
↓
|
|
160
|
+
eval_articles.py (Gemini reads PDF + our markdown)
|
|
161
|
+
↓
|
|
162
|
+
triage findings → encode each as a structural test
|
|
163
|
+
↓ → fix the converter
|
|
164
|
+
re-run, repeat
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The structural test suite is the regression net (deterministic, runs in
|
|
168
|
+
CI). LLM eval is the discovery tool (non-deterministic, runs ad-hoc). Each
|
|
169
|
+
real defect the eval surfaces should be added to
|
|
170
|
+
`tests/test_jats_articles.py` once fixed, so it can never silently regress.
|
|
171
|
+
|
|
172
|
+
## Known limitations
|
|
173
|
+
|
|
174
|
+
- Tables typeset as images (older PLOS Genetics, BMJ, etc.) cannot be
|
|
175
|
+
reconstructed as markdown tables — the converter falls back to an image
|
|
176
|
+
link so content isn't lost, but downstream tools won't get structured
|
|
177
|
+
data without an OCR step.
|
|
178
|
+
- The consortium author rendering for papers like gnomAD (PMC7334197)
|
|
179
|
+
emits the consortium *name* only; individual members listed in nested
|
|
180
|
+
`<contrib-group>` are dropped.
|
|
181
|
+
- Some end-of-article metadata sections (Author contributions, Competing
|
|
182
|
+
interests, Funding, Data availability) live inside `<fn-group>` or
|
|
183
|
+
`<notes>` in `<back>`; these aren't currently rendered.
|
|
184
|
+
- Soft hyphens / line-break artefacts in source XML are not normalised,
|
|
185
|
+
so words split across lines in the JATS source can render with stray
|
|
186
|
+
spaces ("si milarity").
|
litdown-0.3.0/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# litdown
|
|
2
|
+
|
|
3
|
+
[](https://github.com/populationgenomics/litdown/actions/workflows/lint.yaml)
|
|
4
|
+
|
|
5
|
+
Convert scholarly full-text XML to Markdown with embedded LaTeX for inline and
|
|
6
|
+
display math. Two dialects are supported behind a single `convert` entry point
|
|
7
|
+
that sniffs the document root and dispatches:
|
|
8
|
+
|
|
9
|
+
- **JATS** (`<article>`) — the format PubMed Central distributes.
|
|
10
|
+
- **Elsevier** (`<full-text-retrieval-response>`) — the ScienceDirect Article
|
|
11
|
+
Retrieval API's `xocs`/`ja`/`ce` schema.
|
|
12
|
+
|
|
13
|
+
The intended consumer is downstream LLM tooling — the markdown is plain text
|
|
14
|
+
suitable for retrieval, summarisation, or analysis without round-tripping
|
|
15
|
+
through a typesetter.
|
|
16
|
+
|
|
17
|
+
## Spec target
|
|
18
|
+
|
|
19
|
+
The **JATS** dialect is implemented against the **JATS Journal Archiving and
|
|
20
|
+
Interchange Tag Set (Archiving), NISO Z39.96-2024 v1.4** — the format PMC
|
|
21
|
+
distributes. PMC upconverts older content (NLM Archiving 1.x–3.x, JATS
|
|
22
|
+
1.0–1.3) into 1.4 when serving the OA bucket, so a converter that handles 1.4
|
|
23
|
+
covers the entire PMC corpus regardless of when the article was authored.
|
|
24
|
+
|
|
25
|
+
This is **not** the Article Authoring tag set (more restrictive; intended as
|
|
26
|
+
an authoring target, not a corpus). Article-Authoring-only content is a
|
|
27
|
+
subset of Archiving content and works without code changes.
|
|
28
|
+
|
|
29
|
+
The **Elsevier** dialect targets the `ce:`/`ja:`/`xocs:` schema returned by
|
|
30
|
+
the ScienceDirect Article Retrieval API. Math is standard W3C MathML (shared
|
|
31
|
+
with the JATS math path); tables are CALS (`tgroup`/`row`/`entry`); references
|
|
32
|
+
parse the structured `sb:` (Siemens) model. An unrecognised root element
|
|
33
|
+
raises `ValueError` rather than returning an empty string, so a caller passing
|
|
34
|
+
the wrong bytes fails loudly.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install -e . # runtime
|
|
40
|
+
pip install -e '.[dev]' # runtime + pytest
|
|
41
|
+
pip install -r requirements-dev.txt && pre-commit install # contributing
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Editable install. Provides a `litdown` console script.
|
|
45
|
+
|
|
46
|
+
## Use
|
|
47
|
+
|
|
48
|
+
CLI:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
litdown article.xml > article.md
|
|
52
|
+
litdown article.xml article.md
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Library:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from litdown import convert, mml_to_tex, render_mathml
|
|
59
|
+
|
|
60
|
+
md = convert("article.xml") # JATS or Elsevier XML path → markdown
|
|
61
|
+
latex = mml_to_tex(math_element) # MathML Element → LaTeX
|
|
62
|
+
fragment = render_mathml(math_element, display=True) # → "$$...$$"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## What's in the package
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
litdown/
|
|
69
|
+
jats.py JATS XML → Markdown
|
|
70
|
+
elsevier.py Elsevier (ce:/ja:/xocs:) XML → Markdown
|
|
71
|
+
common.py dialect-neutral leaves (tag helpers, table grid, inline wraps)
|
|
72
|
+
mathml.py MathML → LaTeX
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The MathML converter is the more battle-tested piece — it has been graded
|
|
76
|
+
against the W3C MathML 3 Presentation test suite using both Pandoc and a
|
|
77
|
+
Gemini blind-grading harness. The cases that survived grading are checked
|
|
78
|
+
in under `tests/w3c_mml/` with their expected LaTeX in `tests/golden.json`;
|
|
79
|
+
the regression suite re-runs the converter over them on every test run.
|
|
80
|
+
|
|
81
|
+
## Tests and fixtures
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pytest # full suite
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Three test files:
|
|
88
|
+
|
|
89
|
+
- `tests/test_mml_unit.py` — exhaustive per-element MathML cases.
|
|
90
|
+
- `tests/test_jats_articles.py` — structural assertions over real PMC
|
|
91
|
+
articles in `tests/fixtures/<PMCID>/`, parametrised so adding a fixture
|
|
92
|
+
extends the suite automatically. Known per-fixture defects are
|
|
93
|
+
xfail-marked in a `KNOWN_BUGS` dict so the suite stays green; when a fix
|
|
94
|
+
lands the xfail flips to "unexpectedly passed" and forces the entry's
|
|
95
|
+
removal.
|
|
96
|
+
- `tests/test_elsevier_articles.py` — structural assertions over Elsevier
|
|
97
|
+
articles committed as flat `*.xml` files under `tests/fixtures/elsevier/`
|
|
98
|
+
(math not dropped, CALS tables rendered, every cross-ref/float/reference
|
|
99
|
+
anchored). Vendor only CC-BY (`by/4.0`) articles; see
|
|
100
|
+
`docs/elsevier-dialect-plan.md` for how to harvest fixtures.
|
|
101
|
+
|
|
102
|
+
### Fetching test fixtures
|
|
103
|
+
|
|
104
|
+
PMC articles are not redistributed in this repository — each article has
|
|
105
|
+
its own licence (a mix of CC-BY, CC-BY-NC variants, and others), and the
|
|
106
|
+
publisher PDFs in particular carry more restrictive terms. The fixture
|
|
107
|
+
directories are gitignored. To populate them:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
python tools/fetch_pmc.py --manifest tests/fixtures/MANIFEST.txt
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
This reads `tests/fixtures/MANIFEST.txt` (one PMCID per line), pulls each
|
|
114
|
+
article's JATS XML, publisher PDF, plain text, and referenced figure
|
|
115
|
+
assets from the public `pmc-oa-opendata` S3 bucket, and caches them under
|
|
116
|
+
`tests/fixtures/<PMCID>/`. Fetches are idempotent; re-running is cheap.
|
|
117
|
+
|
|
118
|
+
The article-fixture tests skip cleanly when no fixtures are present, so
|
|
119
|
+
`pytest` works against the MathML unit suite alone.
|
|
120
|
+
|
|
121
|
+
## tools/
|
|
122
|
+
|
|
123
|
+
Discovery and evaluation utilities — none are imported by the package or
|
|
124
|
+
needed for normal use.
|
|
125
|
+
|
|
126
|
+
| Script | Purpose |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `fetch_pmc.py` | Cache a PMCID's JATS XML, publisher PDF, plain text and figure assets into `tests/fixtures/<PMCID>/`. Default `core` mode skips supplementary materials; pass `--all` to include them. |
|
|
129
|
+
| `eval_articles.py` | Send fixture PDF + our markdown to Vertex AI Gemini and ask it to enumerate content-fidelity gaps. Findings appended to `eval_findings.jsonl`. Run ad-hoc, not in CI. Requires `LITDOWN_GCP_PROJECT` env var or `--project`. |
|
|
130
|
+
| `test_mml.py` | Run our MathML converter against the W3C test suite and against the npm `mathml-to-latex` package; produce a per-test report. |
|
|
131
|
+
| `grade_mml.py` | Blind A/B grade MathML disagreements against the W3C reference using Gemini. |
|
|
132
|
+
| `build_grading_page.py`, `build_preview_page.py` | Build self-contained HTML pages for human review of the grading runs. |
|
|
133
|
+
| `mml2tex_shim.js` | Node entry point used by `test_mml.py` to call the npm `mathml-to-latex` library. |
|
|
134
|
+
|
|
135
|
+
## The discovery loop
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
fetch_pmc.py (acquire fixture)
|
|
139
|
+
↓
|
|
140
|
+
litdown.convert
|
|
141
|
+
↓
|
|
142
|
+
eval_articles.py (Gemini reads PDF + our markdown)
|
|
143
|
+
↓
|
|
144
|
+
triage findings → encode each as a structural test
|
|
145
|
+
↓ → fix the converter
|
|
146
|
+
re-run, repeat
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The structural test suite is the regression net (deterministic, runs in
|
|
150
|
+
CI). LLM eval is the discovery tool (non-deterministic, runs ad-hoc). Each
|
|
151
|
+
real defect the eval surfaces should be added to
|
|
152
|
+
`tests/test_jats_articles.py` once fixed, so it can never silently regress.
|
|
153
|
+
|
|
154
|
+
## Known limitations
|
|
155
|
+
|
|
156
|
+
- Tables typeset as images (older PLOS Genetics, BMJ, etc.) cannot be
|
|
157
|
+
reconstructed as markdown tables — the converter falls back to an image
|
|
158
|
+
link so content isn't lost, but downstream tools won't get structured
|
|
159
|
+
data without an OCR step.
|
|
160
|
+
- The consortium author rendering for papers like gnomAD (PMC7334197)
|
|
161
|
+
emits the consortium *name* only; individual members listed in nested
|
|
162
|
+
`<contrib-group>` are dropped.
|
|
163
|
+
- Some end-of-article metadata sections (Author contributions, Competing
|
|
164
|
+
interests, Funding, Data availability) live inside `<fn-group>` or
|
|
165
|
+
`<notes>` in `<back>`; these aren't currently rendered.
|
|
166
|
+
- Soft hyphens / line-break artefacts in source XML are not normalised,
|
|
167
|
+
so words split across lines in the JATS source can render with stray
|
|
168
|
+
spaces ("si milarity").
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""litdown — convert scholarly full-text XML to Markdown.
|
|
2
|
+
|
|
3
|
+
Ships two dialects behind a single :func:`convert` entry point, which
|
|
4
|
+
sniffs the document root and dispatches:
|
|
5
|
+
|
|
6
|
+
* **JATS** (``<article>``) — PMC / NLM full text, via :mod:`litdown.jats`.
|
|
7
|
+
* **Elsevier** (``<full-text-retrieval-response>``) — the ScienceDirect
|
|
8
|
+
Article Retrieval API's ``xocs``/``ja``/``ce`` schema, via
|
|
9
|
+
:mod:`litdown.elsevier`.
|
|
10
|
+
|
|
11
|
+
The :mod:`litdown.mathml` MathML→LaTeX converter and the dialect-neutral
|
|
12
|
+
leaves in :mod:`litdown.common` are shared across both dialects.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib.metadata
|
|
18
|
+
import pathlib
|
|
19
|
+
|
|
20
|
+
import defusedxml.ElementTree
|
|
21
|
+
|
|
22
|
+
from litdown import common, elsevier, jats
|
|
23
|
+
|
|
24
|
+
# Re-exported as the package's public API (see __all__): callers use
|
|
25
|
+
# `from litdown import mml_to_tex, render_mathml`.
|
|
26
|
+
from litdown.mathml import mml_to_tex, render_mathml
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
__version__ = importlib.metadata.version('litdown')
|
|
30
|
+
except importlib.metadata.PackageNotFoundError:
|
|
31
|
+
# Package metadata not available — e.g. running directly from the
|
|
32
|
+
# source tree without an editable install.
|
|
33
|
+
__version__ = '0.0.0+unknown'
|
|
34
|
+
|
|
35
|
+
__all__ = ['__version__', 'convert', 'mml_to_tex', 'render_mathml']
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def convert(xml_path: str | pathlib.Path) -> str:
|
|
39
|
+
"""Convert a scholarly full-text XML file to Markdown.
|
|
40
|
+
|
|
41
|
+
Sniffs the root element's local name (a single cheap parse) and
|
|
42
|
+
dispatches to the matching dialect. An unrecognised root raises
|
|
43
|
+
rather than returning ``''`` — a silent empty string would mask
|
|
44
|
+
"wrong bytes" bugs in the caller, the exact failure mode the Elsevier
|
|
45
|
+
dialect was added to fix.
|
|
46
|
+
"""
|
|
47
|
+
tree = defusedxml.ElementTree.parse(xml_path)
|
|
48
|
+
root = tree.getroot()
|
|
49
|
+
if root is None:
|
|
50
|
+
return ''
|
|
51
|
+
|
|
52
|
+
name = common.get_tag(root)
|
|
53
|
+
if name == 'article':
|
|
54
|
+
return jats.render(root)
|
|
55
|
+
if name == 'full-text-retrieval-response':
|
|
56
|
+
return elsevier.render(root)
|
|
57
|
+
raise ValueError(f'unrecognized root element: {root.tag}')
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""CLI: ``python -m litdown article.xml [output.md]``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import litdown
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv: list[str] | None = None) -> int:
|
|
11
|
+
args = sys.argv if argv is None else argv
|
|
12
|
+
if len(args) < 2:
|
|
13
|
+
print(f'Usage: {args[0]} <article.xml> [output.md]', file=sys.stderr)
|
|
14
|
+
return 1
|
|
15
|
+
|
|
16
|
+
md = litdown.convert(args[1])
|
|
17
|
+
|
|
18
|
+
if len(args) >= 3:
|
|
19
|
+
with open(args[2], 'w') as f:
|
|
20
|
+
f.write(md)
|
|
21
|
+
else:
|
|
22
|
+
print(md)
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == '__main__':
|
|
27
|
+
sys.exit(main())
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Dialect-neutral leaves shared by litdown's XML dialects.
|
|
2
|
+
|
|
3
|
+
These helpers carry no JATS- or Elsevier-specific knowledge; they're the
|
|
4
|
+
bits both :mod:`litdown.jats` and :mod:`litdown.elsevier` would otherwise
|
|
5
|
+
duplicate verbatim: namespace-stripping tag helpers, the xlink href
|
|
6
|
+
accessor, table-cell escaping, the inline typographic leaf formatters, and
|
|
7
|
+
the markdown-table grid builder (colspan/rowspan expansion + multi-row
|
|
8
|
+
header collapse).
|
|
9
|
+
|
|
10
|
+
The inline *dispatchers* are deliberately NOT shared — JATS and Elsevier
|
|
11
|
+
diverge on cross-ref/link attribute handling enough that one config-driven
|
|
12
|
+
function would be more tangled than two small ones. Each dialect keeps its
|
|
13
|
+
own dispatcher and calls :func:`inline_wrap` for the shared leaf wrappings.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import xml.etree.ElementTree as ET
|
|
19
|
+
|
|
20
|
+
XLINK_NS = 'http://www.w3.org/1999/xlink'
|
|
21
|
+
MML_NS = 'http://www.w3.org/1998/Math/MathML'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_tag(elem: ET.Element) -> str:
|
|
25
|
+
"""Return an element's local tag name, stripping any ``{ns}`` prefix."""
|
|
26
|
+
tag = elem.tag
|
|
27
|
+
return tag.split('}', 1)[1] if '}' in tag else tag
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Alias: tests and the Elsevier dialect spell it ``_local``; the JATS code
|
|
31
|
+
# spells it ``get_tag``. Same function, two historical names.
|
|
32
|
+
_local = get_tag
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def xlink_href(elem: ET.Element) -> str:
|
|
36
|
+
return elem.get(f'{{{XLINK_NS}}}href') or elem.get('href') or ''
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def md_escape_cell(text: str) -> str:
|
|
40
|
+
"""Escape pipe characters inside a markdown table cell."""
|
|
41
|
+
return text.replace('|', '\\|')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Inline leaf formatters
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
# Canonical inline-styling tag → markdown wrapping template. Each dialect
|
|
49
|
+
# maps its own element names (e.g. JATS <sub> vs Elsevier <inf>) onto these
|
|
50
|
+
# canonical keys before calling inline_wrap.
|
|
51
|
+
_INLINE_WRAP = {
|
|
52
|
+
'italic': '*{}*',
|
|
53
|
+
'bold': '**{}**',
|
|
54
|
+
'sup': '<sup>{}</sup>',
|
|
55
|
+
'sub': '<sub>{}</sub>',
|
|
56
|
+
'underline': '<u>{}</u>',
|
|
57
|
+
'monospace': '`{}`',
|
|
58
|
+
'strike': '~~{}~~',
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def inline_wrap(name: str, inner: str) -> str | None:
|
|
63
|
+
"""Wrap ``inner`` markdown for a canonical inline-styling tag.
|
|
64
|
+
|
|
65
|
+
Returns ``None`` if ``name`` is not a recognised shared leaf, so the
|
|
66
|
+
caller can fall through to its dialect-specific handling.
|
|
67
|
+
"""
|
|
68
|
+
tpl = _INLINE_WRAP.get(name)
|
|
69
|
+
return tpl.format(inner) if tpl is not None else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Markdown-table grid builder
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def expand_rows(raw_rows: list[list[tuple[str, int, int]]]) -> list[list[str]]:
|
|
78
|
+
"""Expand colspan/rowspan into a rectangular grid of strings.
|
|
79
|
+
|
|
80
|
+
Each input row is a list of ``(content, colspan, rowspan)`` tuples.
|
|
81
|
+
|
|
82
|
+
colspan > 1: content in the first slot, empty string in the rest
|
|
83
|
+
(preserves the column label without duplicating it).
|
|
84
|
+
rowspan > 1: content repeated in each spanned row
|
|
85
|
+
(keeps every row self-contained for an LLM reader).
|
|
86
|
+
"""
|
|
87
|
+
if not raw_rows:
|
|
88
|
+
return []
|
|
89
|
+
occupied: dict[tuple[int, int], str] = {}
|
|
90
|
+
for row_idx, cells in enumerate(raw_rows):
|
|
91
|
+
col_idx = 0
|
|
92
|
+
for content, colspan, rowspan in cells:
|
|
93
|
+
# Advance past any cells already occupied by a rowspan above.
|
|
94
|
+
while (row_idx, col_idx) in occupied:
|
|
95
|
+
col_idx += 1
|
|
96
|
+
for dr in range(rowspan):
|
|
97
|
+
for dc in range(colspan):
|
|
98
|
+
# Repeat content across rowspan; use "" for extra colspan slots.
|
|
99
|
+
occupied[(row_idx + dr, col_idx + dc)] = content if dc == 0 else ''
|
|
100
|
+
col_idx += colspan
|
|
101
|
+
|
|
102
|
+
if not occupied:
|
|
103
|
+
return []
|
|
104
|
+
nrows = max(r for r, _ in occupied) + 1
|
|
105
|
+
ncols = max(c for _, c in occupied) + 1
|
|
106
|
+
return [[occupied.get((r, c), '') for c in range(ncols)] for r in range(nrows)]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render_grid(
|
|
110
|
+
header_rows_raw: list[list[tuple[str, int, int]]],
|
|
111
|
+
body_rows_raw: list[list[tuple[str, int, int]]],
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Build a markdown table from normalized ``(content, colspan, rowspan)`` rows.
|
|
114
|
+
|
|
115
|
+
Shared by the JATS (XHTML) and Elsevier (CALS) table renderers: each
|
|
116
|
+
dialect translates its own spanning model into the normalized tuple
|
|
117
|
+
rows, then hands them here. Markdown tables only support a single
|
|
118
|
+
header row, so multi-row headers are collapsed column-by-column with
|
|
119
|
+
" / " joins (Nature / extended-data tables routinely use 2-4 levels).
|
|
120
|
+
"""
|
|
121
|
+
header_rows = expand_rows(header_rows_raw)
|
|
122
|
+
body_rows = expand_rows(body_rows_raw)
|
|
123
|
+
|
|
124
|
+
all_rows = header_rows + body_rows
|
|
125
|
+
if not all_rows:
|
|
126
|
+
return ''
|
|
127
|
+
|
|
128
|
+
ncols = max(len(r) for r in all_rows)
|
|
129
|
+
|
|
130
|
+
def pad(row: list[str]) -> list[str]:
|
|
131
|
+
return row + [''] * (ncols - len(row))
|
|
132
|
+
|
|
133
|
+
def is_decorator(row: list[str]) -> bool:
|
|
134
|
+
text = ''.join(row).strip()
|
|
135
|
+
return text in {'', '<hr/>', '<hr />'}
|
|
136
|
+
|
|
137
|
+
real_header_rows = [pad(r) for r in header_rows if not is_decorator(r)]
|
|
138
|
+
if real_header_rows:
|
|
139
|
+
combined = []
|
|
140
|
+
for col in range(ncols):
|
|
141
|
+
seen: list[str] = []
|
|
142
|
+
for r in real_header_rows:
|
|
143
|
+
v = r[col].strip()
|
|
144
|
+
if v and v not in seen:
|
|
145
|
+
seen.append(v)
|
|
146
|
+
combined.append(' / '.join(seen))
|
|
147
|
+
else:
|
|
148
|
+
combined = []
|
|
149
|
+
|
|
150
|
+
lines = []
|
|
151
|
+
if combined:
|
|
152
|
+
lines.append('| ' + ' | '.join(combined) + ' |')
|
|
153
|
+
lines.append('| ' + ' | '.join(['---'] * ncols) + ' |')
|
|
154
|
+
else:
|
|
155
|
+
lines.append('| ' + ' | '.join([''] * ncols) + ' |')
|
|
156
|
+
lines.append('| ' + ' | '.join(['---'] * ncols) + ' |')
|
|
157
|
+
|
|
158
|
+
for row in body_rows:
|
|
159
|
+
lines.append('| ' + ' | '.join(pad(row)) + ' |')
|
|
160
|
+
|
|
161
|
+
return '\n'.join(lines)
|