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 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
+ [![Lint](https://github.com/populationgenomics/litdown/actions/workflows/lint.yaml/badge.svg)](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").
@@ -0,0 +1,168 @@
1
+ # litdown
2
+
3
+ [![Lint](https://github.com/populationgenomics/litdown/actions/workflows/lint.yaml/badge.svg)](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)