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.
Files changed (46) hide show
  1. cv_tools/__init__.py +7 -0
  2. cv_tools/__main__.py +5 -0
  3. cv_tools/api.py +123 -0
  4. cv_tools/bib.py +122 -0
  5. cv_tools/charset.py +142 -0
  6. cv_tools/cli.py +384 -0
  7. cv_tools/doctor.py +185 -0
  8. cv_tools/engine.py +320 -0
  9. cv_tools/loader.py +424 -0
  10. cv_tools/markup.py +614 -0
  11. cv_tools/migrate.py +222 -0
  12. cv_tools/model.py +192 -0
  13. cv_tools/render/__init__.py +1 -0
  14. cv_tools/render/html.py +581 -0
  15. cv_tools/render/latex.py +154 -0
  16. cv_tools/schema/cv-v2.schema.json +707 -0
  17. cv_tools/starter/cv.yaml +129 -0
  18. cv_tools/starter/publications.bib +55 -0
  19. cv_tools/templates/html/cv.css +252 -0
  20. cv_tools/templates/html/cv.html.jinja +19 -0
  21. cv_tools/templates/html/icons/github.svg +1 -0
  22. cv_tools/templates/html/icons/linkedin.svg +1 -0
  23. cv_tools/templates/html/icons/orcid.svg +1 -0
  24. cv_tools/templates/html/icons/twitter.svg +1 -0
  25. cv_tools/templates/html/macros.jinja +89 -0
  26. cv_tools/templates/latex/cv.tex.jinja +45 -0
  27. cv_tools/templates/latex/layouts/dated.jinja +12 -0
  28. cv_tools/templates/latex/layouts/entries-inline.jinja +23 -0
  29. cv_tools/templates/latex/layouts/entries.jinja +29 -0
  30. cv_tools/templates/latex/layouts/list.jinja +24 -0
  31. cv_tools/templates/latex/layouts/pairs.jinja +14 -0
  32. cv_tools/templates/latex/layouts/publications.jinja +14 -0
  33. cv_tools/templates/latex/layouts/text.jinja +10 -0
  34. cv_tools/templates/latex/orcid.pdf +0 -0
  35. cv_tools/templates/latex/packages.txt +47 -0
  36. cv_tools/templates/latex/resume.cls +265 -0
  37. cv_tools/templates/latex/unicode.txt +398 -0
  38. cv_tools/v1.py +484 -0
  39. cv_tools/validate.py +165 -0
  40. cv_tools/watch.py +76 -0
  41. cv_tools_cli-2.0.0.dist-info/METADATA +498 -0
  42. cv_tools_cli-2.0.0.dist-info/RECORD +46 -0
  43. cv_tools_cli-2.0.0.dist-info/WHEEL +5 -0
  44. cv_tools_cli-2.0.0.dist-info/entry_points.txt +2 -0
  45. cv_tools_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
  46. 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
+ "&": "&amp;",
436
+ "<": "&lt;",
437
+ ">": "&gt;",
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
450
+ if quote:
451
+ text = text.replace('"', "&quot;").replace("'", "&#39;")
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))