cv-tools-cli 2.0.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.
- cv_tools/__init__.py +7 -0
- cv_tools/__main__.py +5 -0
- cv_tools/api.py +123 -0
- cv_tools/bib.py +122 -0
- cv_tools/charset.py +142 -0
- cv_tools/cli.py +384 -0
- cv_tools/doctor.py +185 -0
- cv_tools/engine.py +320 -0
- cv_tools/loader.py +424 -0
- cv_tools/markup.py +614 -0
- cv_tools/migrate.py +222 -0
- cv_tools/model.py +192 -0
- cv_tools/render/__init__.py +1 -0
- cv_tools/render/html.py +581 -0
- cv_tools/render/latex.py +154 -0
- cv_tools/schema/cv-v2.schema.json +707 -0
- cv_tools/starter/cv.yaml +129 -0
- cv_tools/starter/publications.bib +55 -0
- cv_tools/templates/html/cv.css +252 -0
- cv_tools/templates/html/cv.html.jinja +19 -0
- cv_tools/templates/html/icons/github.svg +1 -0
- cv_tools/templates/html/icons/linkedin.svg +1 -0
- cv_tools/templates/html/icons/orcid.svg +1 -0
- cv_tools/templates/html/icons/twitter.svg +1 -0
- cv_tools/templates/html/macros.jinja +89 -0
- cv_tools/templates/latex/cv.tex.jinja +45 -0
- cv_tools/templates/latex/layouts/dated.jinja +12 -0
- cv_tools/templates/latex/layouts/entries-inline.jinja +23 -0
- cv_tools/templates/latex/layouts/entries.jinja +29 -0
- cv_tools/templates/latex/layouts/list.jinja +24 -0
- cv_tools/templates/latex/layouts/pairs.jinja +14 -0
- cv_tools/templates/latex/layouts/publications.jinja +14 -0
- cv_tools/templates/latex/layouts/text.jinja +10 -0
- cv_tools/templates/latex/orcid.pdf +0 -0
- cv_tools/templates/latex/packages.txt +47 -0
- cv_tools/templates/latex/resume.cls +265 -0
- cv_tools/templates/latex/unicode.txt +398 -0
- cv_tools/v1.py +484 -0
- cv_tools/validate.py +165 -0
- cv_tools/watch.py +76 -0
- cv_tools_cli-2.0.0.dist-info/METADATA +498 -0
- cv_tools_cli-2.0.0.dist-info/RECORD +46 -0
- cv_tools_cli-2.0.0.dist-info/WHEEL +5 -0
- cv_tools_cli-2.0.0.dist-info/entry_points.txt +2 -0
- cv_tools_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
- cv_tools_cli-2.0.0.dist-info/top_level.txt +1 -0
cv_tools/markup.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
"""The CV text markup (bold, italic, links, quotes) to LaTeX and to HTML."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import itertools
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional, Union
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Latex:
|
|
14
|
+
"""Raw LaTeX, written in YAML as ``{latex: "..."}``."""
|
|
15
|
+
|
|
16
|
+
latex: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Text = Union[str, int, float, Latex]
|
|
20
|
+
|
|
21
|
+
_SPECIAL = {
|
|
22
|
+
"\\": r"\textbackslash{}",
|
|
23
|
+
"{": r"\{",
|
|
24
|
+
"}": r"\}",
|
|
25
|
+
"#": r"\#",
|
|
26
|
+
"$": r"\$",
|
|
27
|
+
"%": r"\%",
|
|
28
|
+
"&": r"\&",
|
|
29
|
+
"_": r"\_",
|
|
30
|
+
# \textasciitilde is a raised accent in OT1.
|
|
31
|
+
"~": r"\ensuremath{\sim}",
|
|
32
|
+
"^": r"\textasciicircum{}",
|
|
33
|
+
# OT1 prints these three as other glyphs.
|
|
34
|
+
"<": r"\textless{}",
|
|
35
|
+
">": r"\textgreater{}",
|
|
36
|
+
"|": r"\textbar{}",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
ESCAPABLE = frozenset("*[]()'\\")
|
|
40
|
+
|
|
41
|
+
# Characters that \\ and \item would read as an optional argument or a star.
|
|
42
|
+
_LINE_START_GUARD = {"[": "{[}", "*": "{*}"}
|
|
43
|
+
|
|
44
|
+
# ?` and !` are TeX ligatures (¿ and ¡).
|
|
45
|
+
_LIGATURE_FIRST = ("?", "!")
|
|
46
|
+
|
|
47
|
+
_WORD = re.compile(r"\w")
|
|
48
|
+
|
|
49
|
+
LINE_ENDS = re.compile("\r\n|[\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]")
|
|
50
|
+
_CONTROL = re.compile("[\x00-\x08\x0e-\x1b\x1f\x7f-\x84\x86-\x9f]")
|
|
51
|
+
|
|
52
|
+
# Control characters are removed first, so this cannot occur in the text.
|
|
53
|
+
_DATE = "\x00"
|
|
54
|
+
DATE_PLACEHOLDER = "{date}"
|
|
55
|
+
|
|
56
|
+
_URL_ESCAPES = {
|
|
57
|
+
"%": r"\%",
|
|
58
|
+
"#": r"\#",
|
|
59
|
+
"\\": r"\%5C",
|
|
60
|
+
"{": r"\%7B",
|
|
61
|
+
"}": r"\%7D",
|
|
62
|
+
"^": r"\%5E", # ^^ would start a TeX character code in a macro argument
|
|
63
|
+
" ": r"\%20",
|
|
64
|
+
"\t": r"\%09",
|
|
65
|
+
"\n": r"\%0A",
|
|
66
|
+
"\r": r"\%0D",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# A line break inside a table cell, where \\ would end the row.
|
|
71
|
+
CELL_NEWLINE = r"\newline{}"
|
|
72
|
+
|
|
73
|
+
# Deeper markup prints as text, which keeps the recursion within Pyodide's stack.
|
|
74
|
+
MAX_NESTING = 32
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def to_latex(value: Text, date: Optional[str] = None, newline: str = "\\\\") -> str:
|
|
78
|
+
"""Convert a text value to LaTeX, replacing ``{date}`` with ``date``."""
|
|
79
|
+
if isinstance(value, Latex):
|
|
80
|
+
return value.latex
|
|
81
|
+
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
|
82
|
+
raise TypeError(f"not a text value: {value!r}")
|
|
83
|
+
text = normalize(str(value))
|
|
84
|
+
if date is not None:
|
|
85
|
+
text = text.replace(DATE_PLACEHOLDER, _DATE)
|
|
86
|
+
return _Converter(date, newline).convert(text, top=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def normalize(text: str) -> str:
|
|
90
|
+
"""Line ends become ``\\n``; other control characters are removed."""
|
|
91
|
+
return _CONTROL.sub("", LINE_ENDS.sub("\n", text))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def split_date(value: Text | None, newline: str = "\\\\") -> tuple[str, str]:
|
|
95
|
+
"""A ``dated`` layout date as two LaTeX columns, split at its last space."""
|
|
96
|
+
if value is None:
|
|
97
|
+
return ("", "")
|
|
98
|
+
if isinstance(value, Latex):
|
|
99
|
+
cut = _last_split_space(value.latex)
|
|
100
|
+
if cut == -1:
|
|
101
|
+
return ("", value.latex)
|
|
102
|
+
return (value.latex[:cut], value.latex[cut + 1 :])
|
|
103
|
+
head, space, tail = normalize(str(value)).rpartition(" ")
|
|
104
|
+
if not space:
|
|
105
|
+
return ("", to_latex(value, newline=newline))
|
|
106
|
+
return (to_latex(head, newline=newline), to_latex(tail, newline=newline))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _last_split_space(latex: str) -> int:
|
|
110
|
+
depth, last, i = 0, -1, 0
|
|
111
|
+
while i < len(latex):
|
|
112
|
+
c = latex[i]
|
|
113
|
+
if c == "\\":
|
|
114
|
+
i += 2
|
|
115
|
+
continue
|
|
116
|
+
if c == "{":
|
|
117
|
+
depth += 1
|
|
118
|
+
elif c == "}":
|
|
119
|
+
depth -= 1
|
|
120
|
+
elif c == " " and depth == 0:
|
|
121
|
+
last = i
|
|
122
|
+
i += 1
|
|
123
|
+
return last
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def escape(text: str) -> str:
|
|
127
|
+
"""Escape LaTeX special characters without applying any markup."""
|
|
128
|
+
return "".join(_SPECIAL.get(c, c) for c in normalize(text))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def escape_url(url: str) -> str:
|
|
132
|
+
"""Escape a URL for ``\\href`` or ``\\url``."""
|
|
133
|
+
# \href ends the target at a second #, so later ones become %23 (as RFC 3986 asks).
|
|
134
|
+
out = []
|
|
135
|
+
fragment = False
|
|
136
|
+
for c in url.replace(_DATE, DATE_PLACEHOLDER):
|
|
137
|
+
if c == "#" and fragment:
|
|
138
|
+
out.append(r"\%23")
|
|
139
|
+
elif c in _URL_ESCAPES:
|
|
140
|
+
fragment = fragment or c == "#"
|
|
141
|
+
out.append(_URL_ESCAPES[c])
|
|
142
|
+
elif ord(c) > 0x7E or ord(c) < 0x20:
|
|
143
|
+
out.append("".join(f"\\%{b:02X}" for b in c.encode("utf-8")))
|
|
144
|
+
else:
|
|
145
|
+
out.append(c)
|
|
146
|
+
return "".join(out)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def unescape_markup(text: str) -> str:
|
|
150
|
+
"""Resolve the markup escapes (``\\*`` and the like)."""
|
|
151
|
+
return re.sub(r"\\([*\[\]()'\\])", r"\1", text)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_word(c: str) -> bool:
|
|
155
|
+
return bool(_WORD.match(c))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _spaces(run: str) -> str:
|
|
159
|
+
# v1's order: four spaces, tabs, then two spaces.
|
|
160
|
+
run = run.replace(" ", "\\quad ")
|
|
161
|
+
run = run.replace("\t", "\\quad ")
|
|
162
|
+
return re.sub(" {2}", r"\\ ", run)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _escaped(s: str) -> list[bool]:
|
|
166
|
+
marks = [False] * len(s)
|
|
167
|
+
i = 0
|
|
168
|
+
while i < len(s) - 1:
|
|
169
|
+
if s[i] == "\\" and s[i + 1] in ESCAPABLE:
|
|
170
|
+
marks[i + 1] = True
|
|
171
|
+
i += 2
|
|
172
|
+
else:
|
|
173
|
+
i += 1
|
|
174
|
+
return marks
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _Converter:
|
|
178
|
+
"""The markup parser, producing LaTeX; :class:`_HtmlConverter` overrides the output methods."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, date: Optional[str], newline: str = "\\\\"):
|
|
181
|
+
self.date = date
|
|
182
|
+
self.newline = newline
|
|
183
|
+
self.depth = 0
|
|
184
|
+
|
|
185
|
+
def convert(self, s: str, top: bool) -> str:
|
|
186
|
+
self.depth += 1
|
|
187
|
+
try:
|
|
188
|
+
return self._convert(s, top, markup=self.depth <= MAX_NESTING)
|
|
189
|
+
finally:
|
|
190
|
+
self.depth -= 1
|
|
191
|
+
|
|
192
|
+
def _convert(self, s: str, top: bool, markup: bool) -> str:
|
|
193
|
+
esc = _escaped(s)
|
|
194
|
+
out: list[str] = []
|
|
195
|
+
emit = functools.partial(self.emit, out)
|
|
196
|
+
|
|
197
|
+
i, n = 0, len(s)
|
|
198
|
+
line_start = top
|
|
199
|
+
while i < n:
|
|
200
|
+
c = s[i]
|
|
201
|
+
at_line_start, line_start = line_start, False
|
|
202
|
+
if c == "\\" and i + 1 < n and esc[i + 1]:
|
|
203
|
+
emit(self.escaped(s[i + 1], at_line_start))
|
|
204
|
+
i += 2
|
|
205
|
+
continue
|
|
206
|
+
if c == "*" and markup and not esc[i]:
|
|
207
|
+
done = self._emphasis(s, esc, i)
|
|
208
|
+
if done is not None:
|
|
209
|
+
piece, i = done
|
|
210
|
+
emit(piece)
|
|
211
|
+
continue
|
|
212
|
+
if c == "[" and markup and not esc[i]:
|
|
213
|
+
link = _link(s, esc, i)
|
|
214
|
+
if link is not None:
|
|
215
|
+
text, url, end = link
|
|
216
|
+
emit(self.link(url, self.convert(text, False)))
|
|
217
|
+
i = end
|
|
218
|
+
continue
|
|
219
|
+
if c == "'" and markup and not esc[i]:
|
|
220
|
+
j = _close_quote(s, esc, i)
|
|
221
|
+
if j != -1:
|
|
222
|
+
emit(self.quote(self.convert(s[i + 1 : j], False)))
|
|
223
|
+
i = j + 1
|
|
224
|
+
continue
|
|
225
|
+
if c == _DATE:
|
|
226
|
+
emit(self.date_piece())
|
|
227
|
+
i += 1
|
|
228
|
+
continue
|
|
229
|
+
if c == "\n":
|
|
230
|
+
emit(self.line_break(out, top))
|
|
231
|
+
line_start = True
|
|
232
|
+
i += 1
|
|
233
|
+
continue
|
|
234
|
+
if c in " \t":
|
|
235
|
+
j = i
|
|
236
|
+
while j < n and s[j] in " \t":
|
|
237
|
+
j += 1
|
|
238
|
+
run, plain = self.space_run(s[i:j])
|
|
239
|
+
emit(run)
|
|
240
|
+
# TeX skips plain spaces before an optional argument or a star.
|
|
241
|
+
line_start = at_line_start and plain
|
|
242
|
+
i = j
|
|
243
|
+
continue
|
|
244
|
+
emit(self.char(c, at_line_start))
|
|
245
|
+
i += 1
|
|
246
|
+
return "".join(out)
|
|
247
|
+
|
|
248
|
+
def _emphasis(self, s: str, esc: list[bool], i: int) -> Optional[tuple[str, int]]:
|
|
249
|
+
run = _run(s, i)
|
|
250
|
+
if run >= 3:
|
|
251
|
+
j = _find(s, esc, "***", i + 3)
|
|
252
|
+
if j > i + 3:
|
|
253
|
+
return self.bold_italic(self.convert(s[i + 3 : j], False)), j + 3
|
|
254
|
+
j = _close_italic(s, esc, i)
|
|
255
|
+
if j > i + 1:
|
|
256
|
+
return self.italic(self.convert(s[i + 1 : j], False)), j + 1
|
|
257
|
+
if run >= 2:
|
|
258
|
+
j = _close_bold(s, esc, i)
|
|
259
|
+
if j != -1:
|
|
260
|
+
# In "**a *b***" the last two stars close the bold run.
|
|
261
|
+
extra = _run(s, j) - 2
|
|
262
|
+
if extra > 0 and _stars(s, esc, i + 2, j) % 2 == 1:
|
|
263
|
+
j += 1
|
|
264
|
+
return self.bold(self.convert(s[i + 2 : j], False)), j + 2
|
|
265
|
+
j = _close_italic(s, esc, i)
|
|
266
|
+
if j > i + 1:
|
|
267
|
+
return self.italic(self.convert(s[i + 1 : j], False)), j + 1
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
def emit(self, out: list[str], piece: str) -> None:
|
|
271
|
+
if piece.startswith("`") and out and out[-1].endswith(_LIGATURE_FIRST):
|
|
272
|
+
out.append("{}")
|
|
273
|
+
out.append(piece)
|
|
274
|
+
|
|
275
|
+
def escaped(self, literal: str, at_line_start: bool) -> str:
|
|
276
|
+
if at_line_start and literal in _LINE_START_GUARD:
|
|
277
|
+
return _LINE_START_GUARD[literal]
|
|
278
|
+
return _SPECIAL.get(literal, literal)
|
|
279
|
+
|
|
280
|
+
def char(self, c: str, at_line_start: bool) -> str:
|
|
281
|
+
if at_line_start and c in _LINE_START_GUARD:
|
|
282
|
+
return _LINE_START_GUARD[c]
|
|
283
|
+
return _SPECIAL.get(c, c)
|
|
284
|
+
|
|
285
|
+
def bold(self, inner: str) -> str:
|
|
286
|
+
return r"\textbf{" + inner + "}"
|
|
287
|
+
|
|
288
|
+
def italic(self, inner: str) -> str:
|
|
289
|
+
return r"\textit{" + inner + "}"
|
|
290
|
+
|
|
291
|
+
def bold_italic(self, inner: str) -> str:
|
|
292
|
+
return r"\textbf{\textit{" + inner + "}}"
|
|
293
|
+
|
|
294
|
+
def link(self, url: str, inner: str) -> str:
|
|
295
|
+
return r"\href{" + escape_url(unescape_markup(url)) + "}{" + inner + "}"
|
|
296
|
+
|
|
297
|
+
def quote(self, inner: str) -> str:
|
|
298
|
+
return "``" + inner + "''"
|
|
299
|
+
|
|
300
|
+
def date_piece(self) -> str:
|
|
301
|
+
# {} lets letters follow ({date}th).
|
|
302
|
+
return (self.date or "") + "{}"
|
|
303
|
+
|
|
304
|
+
def line_break(self, out: list[str], top: bool) -> str:
|
|
305
|
+
# \\ before any text would end a line that does not exist yet.
|
|
306
|
+
return self.newline if "".join(out).strip() or not top else "\\leavevmode" + self.newline
|
|
307
|
+
|
|
308
|
+
def space_run(self, run: str) -> tuple[str, bool]:
|
|
309
|
+
latex = _spaces(run)
|
|
310
|
+
return latex, latex.strip(" ") == ""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _run(s: str, i: int) -> int:
|
|
314
|
+
j = i
|
|
315
|
+
while j < len(s) and s[j] == "*":
|
|
316
|
+
j += 1
|
|
317
|
+
return j - i
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _positions(s: str, esc: list[bool], start: int):
|
|
321
|
+
"""Indices up to the end of the line that can carry markup (not escaped, not inside a link)."""
|
|
322
|
+
k = start
|
|
323
|
+
while k < len(s) and s[k] != "\n":
|
|
324
|
+
if esc[k]:
|
|
325
|
+
k += 1
|
|
326
|
+
continue
|
|
327
|
+
if s[k] == "[":
|
|
328
|
+
link = _link(s, esc, k)
|
|
329
|
+
if link is not None:
|
|
330
|
+
k = link[2]
|
|
331
|
+
continue
|
|
332
|
+
yield k
|
|
333
|
+
k += 1
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _find(s: str, esc: list[bool], token: str, start: int) -> int:
|
|
337
|
+
for k in _positions(s, esc, start):
|
|
338
|
+
if s.startswith(token, k):
|
|
339
|
+
return k
|
|
340
|
+
return -1
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _stars(s: str, esc: list[bool], start: int, end: int) -> int:
|
|
344
|
+
count = 0
|
|
345
|
+
for k in _positions(s, esc, start):
|
|
346
|
+
if k >= end:
|
|
347
|
+
break
|
|
348
|
+
count += s[k] == "*"
|
|
349
|
+
return count
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _close_bold(s: str, esc: list[bool], i: int) -> int:
|
|
353
|
+
return _find(s, esc, "**", i + 2)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _close_italic(s: str, esc: list[bool], i: int) -> int:
|
|
357
|
+
# Bold runs inside are skipped, because v1 converted bold first.
|
|
358
|
+
skip_to = i + 1
|
|
359
|
+
for k in _positions(s, esc, i + 1):
|
|
360
|
+
if k < skip_to:
|
|
361
|
+
continue
|
|
362
|
+
if s.startswith("**", k):
|
|
363
|
+
j = _close_bold(s, esc, k)
|
|
364
|
+
if j != -1:
|
|
365
|
+
skip_to = j + 2
|
|
366
|
+
continue
|
|
367
|
+
if s[k] == "*":
|
|
368
|
+
return k
|
|
369
|
+
return -1
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _close_quote(s: str, esc: list[bool], i: int) -> int:
|
|
373
|
+
if i > 0 and _is_word(s[i - 1]):
|
|
374
|
+
return -1
|
|
375
|
+
j = s.find("'", i + 1)
|
|
376
|
+
while j != -1 and esc[j]:
|
|
377
|
+
j = s.find("'", j + 1)
|
|
378
|
+
if j <= i + 1 or (j + 1 < len(s) and _is_word(s[j + 1])):
|
|
379
|
+
return -1
|
|
380
|
+
return j
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _link(s: str, esc: list[bool], i: int) -> Optional[tuple[str, str, int]]:
|
|
384
|
+
"""``(text, url, end)`` of a ``[text](url)`` link at ``i``; balanced brackets may nest."""
|
|
385
|
+
close = _balanced(s, esc, i, "[", "]")
|
|
386
|
+
if close == -1 or close + 1 >= len(s) or s[close + 1] != "(" or esc[close + 1]:
|
|
387
|
+
return None
|
|
388
|
+
end = _balanced(s, esc, close + 1, "(", ")")
|
|
389
|
+
if end == -1:
|
|
390
|
+
return None
|
|
391
|
+
return s[i + 1 : close], s[close + 2 : end], end + 1
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _balanced(s: str, esc: list[bool], i: int, open_: str, close: str) -> int:
|
|
395
|
+
depth = 0
|
|
396
|
+
for k in range(i, len(s)):
|
|
397
|
+
c = s[k]
|
|
398
|
+
if c == "\n":
|
|
399
|
+
return -1
|
|
400
|
+
if esc[k]:
|
|
401
|
+
continue
|
|
402
|
+
if c == open_:
|
|
403
|
+
depth += 1
|
|
404
|
+
elif c == close:
|
|
405
|
+
depth -= 1
|
|
406
|
+
if depth == 0:
|
|
407
|
+
return k
|
|
408
|
+
return -1
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def v1_decode(text: str) -> str:
|
|
412
|
+
"""The v1 ``decode_special_characters``, byte for byte (nothing is escaped)."""
|
|
413
|
+
text = re.sub(r"\*\*(.*?)\*\*", r"\\textbf{\1}", text)
|
|
414
|
+
text = re.sub(r"\*(.*?)\*", r"\\textit{\1}", text)
|
|
415
|
+
text = re.sub(r"\[(.*?)\]\((.*?)\)", r"\\href{\2}{\1}", text)
|
|
416
|
+
text = text.replace("\n", "\\\\")
|
|
417
|
+
text = re.sub(r" {4}", r"\\quad ", text)
|
|
418
|
+
text = re.sub(r"\t", r"\\quad ", text)
|
|
419
|
+
text = re.sub(r" {2}", r"\\ ", text)
|
|
420
|
+
text = re.sub(r"(?<!\w)'([^']+)'(?!\w)", r"``\1''", text)
|
|
421
|
+
return text
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# HTML, for the live preview, from the same parser as the LaTeX.
|
|
425
|
+
|
|
426
|
+
SAFE_SCHEMES = ("http", "https", "mailto")
|
|
427
|
+
_SCHEME = re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*):")
|
|
428
|
+
# What browsers drop from a URL before they read the scheme.
|
|
429
|
+
_URL_DROPPED = re.compile("[\t\n\r]")
|
|
430
|
+
_C0_AND_SPACE = "".join(chr(c) for c in range(0x21))
|
|
431
|
+
_URL_ENCODED = re.compile("[\x00-\x20\x7f-\x9f\"'<>\\\\`{}|^]")
|
|
432
|
+
|
|
433
|
+
# Characters that pdflatex prints as other glyphs in OT1.
|
|
434
|
+
_HTML_CHAR = {
|
|
435
|
+
"&": "&",
|
|
436
|
+
"<": "<",
|
|
437
|
+
">": ">",
|
|
438
|
+
'"': "”",
|
|
439
|
+
"~": "∼",
|
|
440
|
+
"^": "ˆ",
|
|
441
|
+
}
|
|
442
|
+
_TEX_LIGATURES = re.compile("---|--|``|''|`|'")
|
|
443
|
+
_LIGATURE_GLYPH = {"---": "—", "--": "–", "``": "“", "''": "”", "`": "‘", "'": "’"}
|
|
444
|
+
_TAG_OR_TEXT = re.compile(r'(<code class="cv-latex">[^<]*</code>|<[^>]*>)')
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def escape_html(text: str, quote: bool = True) -> str:
|
|
448
|
+
"""Escape text for an HTML text node or a double-quoted attribute value."""
|
|
449
|
+
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
450
|
+
if quote:
|
|
451
|
+
text = text.replace('"', """).replace("'", "'")
|
|
452
|
+
return text
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def safe_url(url: str) -> Optional[str]:
|
|
456
|
+
"""``url`` as an ``href`` (still to be escaped) when its scheme is http, https or mailto, else None."""
|
|
457
|
+
# The scheme is checked after the cleanup, so the browser sees the scheme that was checked.
|
|
458
|
+
url = _URL_DROPPED.sub("", url).strip(_C0_AND_SPACE)
|
|
459
|
+
url = _URL_ENCODED.sub(lambda m: "".join(f"%{b:02X}" for b in m.group().encode("utf-8")), url)
|
|
460
|
+
m = _SCHEME.match(url)
|
|
461
|
+
if m is None or m.group(1).lower() not in SAFE_SCHEMES:
|
|
462
|
+
return None
|
|
463
|
+
return url
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def tex_ligatures_text(text: str) -> str:
|
|
467
|
+
"""Apply the TeX ligatures (quotes and dashes) to plain text."""
|
|
468
|
+
return _TEX_LIGATURES.sub(lambda m: _LIGATURE_GLYPH[m.group()], text)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def tex_ligatures(html: str) -> str:
|
|
472
|
+
"""Apply the TeX ligatures to the text between tags (never across a tag, as in TeX)."""
|
|
473
|
+
parts = _TAG_OR_TEXT.split(html)
|
|
474
|
+
for i in range(0, len(parts), 2):
|
|
475
|
+
if parts[i]:
|
|
476
|
+
parts[i] = tex_ligatures_text(parts[i])
|
|
477
|
+
return "".join(parts)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def latex_html(latex: str) -> str:
|
|
481
|
+
"""Raw ``{latex: ...}`` text as a visible placeholder."""
|
|
482
|
+
return '<code class="cv-latex">' + escape_html(latex, quote=False) + "</code>"
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class _HtmlConverter(_Converter):
|
|
486
|
+
def emit(self, out: list[str], piece: str) -> None:
|
|
487
|
+
out.append(piece)
|
|
488
|
+
|
|
489
|
+
def escaped(self, literal: str, at_line_start: bool) -> str:
|
|
490
|
+
return self.char(literal, at_line_start)
|
|
491
|
+
|
|
492
|
+
def char(self, c: str, at_line_start: bool) -> str:
|
|
493
|
+
return _HTML_CHAR.get(c, c)
|
|
494
|
+
|
|
495
|
+
def bold(self, inner: str) -> str:
|
|
496
|
+
return "<strong>" + inner + "</strong>"
|
|
497
|
+
|
|
498
|
+
def italic(self, inner: str) -> str:
|
|
499
|
+
return "<em>" + inner + "</em>"
|
|
500
|
+
|
|
501
|
+
def bold_italic(self, inner: str) -> str:
|
|
502
|
+
return "<strong><em>" + inner + "</em></strong>"
|
|
503
|
+
|
|
504
|
+
def link(self, url: str, inner: str) -> str:
|
|
505
|
+
href = safe_url(unescape_markup(url).replace(_DATE, DATE_PLACEHOLDER))
|
|
506
|
+
if href is None:
|
|
507
|
+
return '<span class="cv-link">' + inner + "</span>"
|
|
508
|
+
return _split_link('<a class="cv-link" href="' + escape_html(href) + '">', inner)
|
|
509
|
+
|
|
510
|
+
def quote(self, inner: str) -> str:
|
|
511
|
+
# TeX quotes, so tex_ligatures combines them with neighbouring quotes as TeX does.
|
|
512
|
+
return "``" + inner + "''"
|
|
513
|
+
|
|
514
|
+
def date_piece(self) -> str:
|
|
515
|
+
return self.date or ""
|
|
516
|
+
|
|
517
|
+
def line_break(self, out: list[str], top: bool) -> str:
|
|
518
|
+
return "<br>"
|
|
519
|
+
|
|
520
|
+
def space_run(self, run: str) -> tuple[str, bool]:
|
|
521
|
+
latex, plain = super().space_run(run)
|
|
522
|
+
if plain:
|
|
523
|
+
return " ", True
|
|
524
|
+
units = re.findall(r"\\quad *|\\ | +", latex)
|
|
525
|
+
html = "".join('<span class="cv-quad"></span>' if u.startswith("\\quad") else " " for u in units)
|
|
526
|
+
return '<span class="cv-space">' + html + "</span>", False
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
_TAG = re.compile(r"(<[^>]*>)")
|
|
530
|
+
_VOID = frozenset({"br"})
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _split_link(open_a: str, inner: str) -> str:
|
|
534
|
+
"""``open_a + inner + "</a>"``, split around the links inside ``inner``.
|
|
535
|
+
|
|
536
|
+
HTML cannot nest links, so the outer link wraps each run between inner
|
|
537
|
+
ones, as the PDF's inner link areas lie on top of the outer one.
|
|
538
|
+
"""
|
|
539
|
+
if "<a " not in inner:
|
|
540
|
+
return open_a + inner + "</a>"
|
|
541
|
+
tokens = [t for t in _TAG.split(inner) if t]
|
|
542
|
+
# Each piece of content with the (serial number, opening tag) of the elements around it.
|
|
543
|
+
leaves: list[tuple[tuple[tuple[int, str], ...], str]] = []
|
|
544
|
+
chain: list[tuple[int, str]] = []
|
|
545
|
+
i = 0
|
|
546
|
+
while i < len(tokens):
|
|
547
|
+
token = tokens[i]
|
|
548
|
+
if token.startswith("</"):
|
|
549
|
+
chain.pop()
|
|
550
|
+
elif token.startswith("<") and _tag_name(token) not in _VOID:
|
|
551
|
+
close = f"</{_tag_name(token)}>"
|
|
552
|
+
if i + 1 < len(tokens) and tokens[i + 1] == close:
|
|
553
|
+
leaves.append((tuple(chain), token + close))
|
|
554
|
+
i += 2
|
|
555
|
+
continue
|
|
556
|
+
chain.append((i, token))
|
|
557
|
+
else:
|
|
558
|
+
leaves.append((tuple(chain), token))
|
|
559
|
+
i += 1
|
|
560
|
+
|
|
561
|
+
def inner_link(leaf: tuple) -> Optional[int]:
|
|
562
|
+
return next((n for n, tag in leaf[0] if tag.startswith("<a ")), None)
|
|
563
|
+
|
|
564
|
+
out = []
|
|
565
|
+
for link, group in itertools.groupby(leaves, key=inner_link):
|
|
566
|
+
body = _nest(list(group))
|
|
567
|
+
out.append(body if link is not None else open_a + body + "</a>")
|
|
568
|
+
return "".join(out)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _nest(leaves: list[tuple[tuple[tuple[int, str], ...], str]]) -> str:
|
|
572
|
+
out: list[str] = []
|
|
573
|
+
current: tuple[tuple[int, str], ...] = ()
|
|
574
|
+
for chain, html in leaves:
|
|
575
|
+
k = 0
|
|
576
|
+
while k < min(len(current), len(chain)) and current[k] == chain[k]:
|
|
577
|
+
k += 1
|
|
578
|
+
out.extend(f"</{_tag_name(tag)}>" for _, tag in reversed(current[k:]))
|
|
579
|
+
out.extend(tag for _, tag in chain[k:])
|
|
580
|
+
out.append(html)
|
|
581
|
+
current = chain
|
|
582
|
+
out.extend(f"</{_tag_name(tag)}>" for _, tag in reversed(current))
|
|
583
|
+
return "".join(out)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _tag_name(tag: str) -> str:
|
|
587
|
+
return re.match(r"</?([a-z0-9]+)", tag).group(1) # type: ignore[union-attr]
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def to_html(value: Text, date: Optional[str] = None) -> str:
|
|
591
|
+
"""Convert a text value to HTML, replacing ``{date}`` with ``date``."""
|
|
592
|
+
if isinstance(value, Latex):
|
|
593
|
+
return latex_html(value.latex)
|
|
594
|
+
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
|
595
|
+
raise TypeError(f"not a text value: {value!r}")
|
|
596
|
+
text = normalize(str(value))
|
|
597
|
+
if date is not None:
|
|
598
|
+
text = text.replace(DATE_PLACEHOLDER, _DATE)
|
|
599
|
+
return tex_ligatures(_HtmlConverter(date).convert(text, top=True))
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def split_date_html(value: Text | None) -> tuple[str, str]:
|
|
603
|
+
""":func:`split_date` in HTML."""
|
|
604
|
+
if value is None:
|
|
605
|
+
return ("", "")
|
|
606
|
+
if isinstance(value, Latex):
|
|
607
|
+
cut = _last_split_space(value.latex)
|
|
608
|
+
if cut == -1:
|
|
609
|
+
return ("", latex_html(value.latex))
|
|
610
|
+
return (latex_html(value.latex[:cut]), latex_html(value.latex[cut + 1 :]))
|
|
611
|
+
head, space, tail = normalize(str(value)).rpartition(" ")
|
|
612
|
+
if not space:
|
|
613
|
+
return ("", to_html(value))
|
|
614
|
+
return (to_html(head), to_html(tail))
|