Coverage for gpaw/doctools/aamath.py: 57%
163 statements
« prev ^ index » next coverage.py v7.7.1, created at 2025-07-12 00:18 +0000
« prev ^ index » next coverage.py v7.7.1, created at 2025-07-12 00:18 +0000
1r"""
2Ascii-art math to LaTeX converter
3=================================
5Examples:
7::
9 1+2
10 ---
11 3
13.. math::
15 \frac{1+2}{3}
17::
19 <a|b>
21.. math::
23 \langle a|b \rangle
25::
27 / _ ~ ~ --- a
28 | dr 𝜓 𝜓 + > S
29 / m n --- ij
30 aij
32.. math::
34 \int d\mathbf{r} \tilde{𝜓}_{m} \tilde{𝜓}_{n} + \sum^{}_{aij} S^{a}_{ij}
35"""
37from __future__ import annotations
40def prep(lines: list[str], n: int | None) -> tuple[list[str], int | None]:
41 """Preprocess lines.
43 * Remove leading and trailing empty lines.
44 * Make all lines have the same length (pad with spaces).
45 * Remove spaces from beginning of lines.
46 """
47 if not lines:
48 return [], n
49 while lines and not lines[0].strip():
50 lines.pop(0)
51 if n is not None:
52 n -= 1
53 while lines and not lines[-1].strip():
54 lines.pop()
55 if not lines:
56 return [], 0
57 i = min(len(line) - len(line.lstrip()) for line in lines)
58 lines = [line[i:] for line in lines]
59 i = max(len(line) for line in lines)
60 return [line.ljust(i) for line in lines], n
63class ParseError(Exception):
64 """Bad ascii-art math."""
67def cut(lines: list[str], i1: int, i2: int = None) -> list[str]:
68 """Cut out block.
70 >>> cut(['012345', 'abcdef'], 1, 3)
71 ['12', 'bc']
72 """
73 index = slice(i1, i2)
74 return [line[index] for line in lines]
77def block(lines: list[str]) -> dict[int, str]:
78 r"""Find superscript/subscript blocks.
80 >>> block([' 2 _ ',
81 ... ' k '])
82 {0: '2', 3: '\\mathbf{k}'}
83 """
84 if not lines:
85 return {}
86 blocks = {}
87 i1 = None
88 for i in range(len(lines[0])):
89 if all(line[i] == ' ' for line in lines):
90 if i1 is not None:
91 blocks[i1 - 1] = parse(cut(lines, i1, i))
92 i1 = None
93 else:
94 if i1 is None:
95 i1 = i
96 if i1 is not None:
97 blocks[i1 - 1] = parse(cut(lines, i1))
98 return blocks
101def parse(lines: str | list[str], n: int = None) -> str:
102 r"""Parse ascii-art math to LaTeX.
104 >>> parse([' / ~ ',
105 ... ' |dx p (x) ',
106 ... ' / ai '])
107 '\\int dx \\tilde{p}_{ai} (x)'
108 >>> parse([' _ _ ',
109 ... ' ik.r ',
110 ... ' e '])
111 'e^{i\\mathbf{k}\\cdot \\mathbf{r}}'
112 """
113 if isinstance(lines, str):
114 lines = lines.splitlines()
116 lines, n = prep(lines, n)
118 if not not False:
119 print('*********************************************')
120 print(n)
121 print('\n'.join(lines))
123 if not lines:
124 return ''
125 if n is None:
126 N = max((len(line.replace(' ', '')), n)
127 for n, line in enumerate(lines))[1]
128 for n in [N] + [n for n in range(len(lines)) if n != N]:
129 try:
130 latex = parse(lines, n)
131 except ParseError:
132 continue
133 return latex
134 raise ParseError('Could not parse\n\n' + ' \n'.join(lines))
136 line = lines[n]
137 i1 = line.find('--')
138 if i1 != -1:
139 i2 = len(line) - len(line[i1:].lstrip('-'))
140 p1 = parse(cut(lines, 0, i1), n)
141 p2 = parse(cut(lines[:n], i1, i2))
142 p3 = parse(cut(lines[n + 1:], i1, i2))
143 p4 = parse(cut(lines, i2), n)
144 return rf'{p1} \frac{{{p2}}}{{{p3}}} {p4}'.strip()
146 i = line.find('>')
147 if i != -1 and n > 0 and lines[n - 1][i] == '-':
148 line1 = lines[n - 1]
149 i2 = len(line1) - len(line1[i:].lstrip('-'))
150 p1 = parse(cut(lines, 0, i), n)
151 p2 = parse(cut(lines[:n - 1], i, i2))
152 p3 = parse(cut(lines[n + 2:], i, i2))
153 p4 = parse(cut(lines, i2), n)
154 return rf'{p1} \sum^{{{p2}}}_{{{p3}}} {p4}'.strip()
156 i = line.find('|')
157 if i != -1:
158 if n > 0 and lines[n - 1][i] == '/':
159 p1 = parse(cut(lines, 0, i), n)
160 p2 = parse(cut(lines, i + 1), n)
161 return rf'{p1} \int {p2}'.strip()
162 i1 = line.find('<')
163 i2 = line.find('>')
164 if i1 != -1 and i1 <= i and i2 != -1 and i2 >= i:
165 p1 = parse(cut(lines, 0, i1), n)
166 p2 = parse(cut(lines, i1 + 1, i), n)
167 p3 = parse(cut(lines, i + 1, i2), n)
168 p4 = parse(cut(lines, i2 + 1), n)
169 return rf'{p1} \langle {p2}|{p3} \rangle {p4}'.strip()
171 hats = {}
172 if n > 0:
173 new = []
174 for i, c in enumerate(lines[n - 1]):
175 if c in '^~_':
176 hats[i] = c
177 c = ' '
178 new.append(c)
179 lines[n - 1] = ''.join(new)
181 superscripts = block(lines[:n])
182 subscripts = block(lines[n + 1:])
184 results = []
185 for i, c in enumerate(line):
187 if i in hats:
188 hat = {'^': 'hat', '~': 'tilde', '_': 'mathbf'}[hats[i]]
189 c = rf'\{hat}{{{c}}}'
190 sup = superscripts.pop(i, None)
191 if sup:
192 c = rf'{c}^{{{sup}}}'
193 sub = subscripts.pop(i, None)
194 if sub:
195 c = rf'{c}_{{{sub}}}'
196 c = {'.': r'\cdot '}.get(c, c)
197 results.append(c)
199 if superscripts or subscripts:
200 raise ParseError(f'super={superscripts}, sub={subscripts}')
202 latex = ''.join(results).strip()
204 for sequence, replacement in [
205 ('->', r'\rightarrow'),
206 ('<-', r'\leftarrow')]:
207 latex = latex.replace(sequence, replacement)
209 return latex
212def autodoc_process_docstring(lines):
213 """Hook-function for Sphinx."""
214 blocks = []
215 for i1, line in enumerate(lines):
216 if line.endswith(':::'):
217 for i2, line in enumerate(lines[i1 + 2:], i1 + 2):
218 if not line:
219 break
220 else:
221 i2 += 1
222 blocks.append((i1, i2))
223 for i1, i2 in reversed(blocks):
224 latex = parse(lines[i1 + 1:i2])
225 line = f'.. math:: {latex}'
226 if lines[i1].strip() == ':::':
227 lines[i1:i2] = [line]
228 else:
229 lines[i1:i2] = [lines[i1][:-2], '', line]
232def test_examples():
233 """Test examples from module docstring."""
234 lines = __doc__.replace('\n::', '\n:::').splitlines()
235 autodoc_process_docstring(lines)
236 for example in '\n'.join(lines).split('.. math:: ')[1:]:
237 line1, *lines = example.splitlines()
238 line2 = lines[3].strip()
239 assert line1 == line2
242def main():
243 import sys
244 import argparse
245 import importlib
246 parser = argparse.ArgumentParser(
247 description='Parse docstring with ascii-art math.')
248 parser.add_argument(
249 'thing',
250 nargs='?',
251 help='Name of module, class, method or '
252 'function. Examples: "module.submodule", '
253 '"module.Class", "module.Class.method", '
254 '"module.function". Will read from stdin if not given.')
255 args = parser.parse_args()
256 if args.thing is None:
257 lines = sys.stdin.read().splitlines()
258 print(parse(lines))
259 else:
260 parts = args.thing.split('.')
261 for i, part in enumerate(parts):
262 if not part.islower():
263 mod = importlib.import_module('.'.join(parts[:i]))
264 break
265 else:
266 # no break
267 try:
268 mod = importlib.import_module('.'.join(parts))
269 i += 1
270 except ImportError:
271 mod = importlib.import_module('.'.join(parts[:-1]))
272 thing = mod
273 for part in parts[i:]:
274 thing = getattr(thing, part)
275 lines = thing.__doc__.splitlines()
276 autodoc_process_docstring(lines)
277 print('\n'.join(lines))
280if __name__ == '__main__':
281 main()