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 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)