litdown 0.3.0__py3-none-any.whl
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/__init__.py +57 -0
- litdown/__main__.py +27 -0
- litdown/common.py +161 -0
- litdown/elsevier.py +1292 -0
- litdown/jats.py +1512 -0
- litdown/mathml.py +1080 -0
- litdown/py.typed +0 -0
- litdown-0.3.0.dist-info/METADATA +186 -0
- litdown-0.3.0.dist-info/RECORD +13 -0
- litdown-0.3.0.dist-info/WHEEL +5 -0
- litdown-0.3.0.dist-info/entry_points.txt +2 -0
- litdown-0.3.0.dist-info/licenses/LICENSE +21 -0
- litdown-0.3.0.dist-info/top_level.txt +1 -0
litdown/__init__.py
ADDED
|
@@ -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}')
|
litdown/__main__.py
ADDED
|
@@ -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())
|
litdown/common.py
ADDED
|
@@ -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)
|