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
@@ -0,0 +1,581 @@
1
+ """The HTML live preview of the web app, styled to look like the PDF.
2
+
3
+ All input is untrusted: text is escaped and only http, https and mailto links are emitted.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import codecs
9
+ import datetime as _dt
10
+ import functools
11
+ import re
12
+ import unicodedata
13
+ from dataclasses import dataclass
14
+ from importlib import resources
15
+ from typing import Any, Optional, Union
16
+
17
+ import jinja2
18
+ from markupsafe import Markup, escape
19
+
20
+ from ..bib import Publication
21
+ from ..loader import YamlPath
22
+ from ..markup import (
23
+ MAX_NESTING, Latex, Text, _last_split_space, _split_link, escape_html, latex_html, safe_url, split_date_html,
24
+ tex_ligatures_text, to_html,
25
+ )
26
+ from ..model import ICON_KINDS, ContactItem, ContactLine, Document, Entry, Section
27
+
28
+ TEMPLATE_DIR = resources.files("cv_tools") / "templates" / "html"
29
+
30
+ # (CSS family, weight, style, file name) of the fonts the web app self-hosts.
31
+ FONT_FILES = (
32
+ ("CMU Serif", "normal", "normal", "cmunrm"),
33
+ ("CMU Serif", "bold", "normal", "cmunbx"),
34
+ ("CMU Serif", "normal", "italic", "cmunti"),
35
+ ("CMU Serif", "bold", "italic", "cmunbi"),
36
+ ("CMU Typewriter Text", "normal", "normal", "cmuntt"),
37
+ ("Latin Modern Math", "normal", "normal", "latinmodern-math"),
38
+ )
39
+
40
+ # Characters pdflatex prints that some CMU face lacks and cannot compose,
41
+ # taken from Latin Modern Math (downloaded only when a document uses one).
42
+ FONT_RANGES = {
43
+ "latinmodern-math": "U+03D5-03D6, U+03F1, U+2015, U+2194, U+21D2, U+2202, U+2207-2209, U+220F, U+2211, U+221E, "
44
+ "U+223C, U+2248, U+2264-2265, U+2282, U+25EF, U+27E8-27E9",
45
+ }
46
+
47
+ # The icons are the paths of orcid.pdf and the FontAwesome 4 glyphs of resume.cls
48
+ # (SIL OFL 1.1), with the glyph's advance width by one em as view box.
49
+ ICON_LABELS = {"orcid": "ORCID iD", "linkedin": "LinkedIn", "github": "GitHub", "twitter": "Twitter"}
50
+
51
+ _MONTHS = ("January", "February", "March", "April", "May", "June", "July",
52
+ "August", "September", "October", "November", "December")
53
+
54
+ _env: Optional[jinja2.Environment] = None
55
+ _css: Optional[str] = None
56
+
57
+ # The app renders on every keystroke. Equal values can print differently
58
+ # (0.0 and -0.0), so the key also has the type and repr.
59
+ @functools.lru_cache(maxsize=4096)
60
+ def _to_html_cached(kind: type, key: str, value: Text, date: Optional[str]) -> str:
61
+ return to_html(value, date=date)
62
+
63
+
64
+ def _to_html(value: Text, date: Optional[str] = None) -> str:
65
+ return _to_html_cached(type(value), repr(value), value, date)
66
+
67
+
68
+ def clear_caches() -> None:
69
+ """Empty the conversion caches."""
70
+ _to_html_cached.cache_clear()
71
+ _rigid.cache_clear()
72
+ latex_text_html.cache_clear()
73
+ citation_html.cache_clear()
74
+
75
+
76
+ _to_html.cache_clear = clear_caches # type: ignore[attr-defined]
77
+
78
+
79
+ @dataclass
80
+ class HtmlResult:
81
+ """The rendered preview; ``editable`` is True for a v2 file, whose elements carry ``data-path``."""
82
+
83
+ html: str
84
+ css: str
85
+ pages: int
86
+ editable: bool
87
+
88
+ def standalone(self, title: str = "CV preview", font_url: Optional[str] = None) -> str:
89
+ """A complete HTML page with the stylesheet inlined, and the fonts from ``font_url`` if given."""
90
+ fonts = font_face_css(font_url) if font_url is not None else ""
91
+ return (
92
+ '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
93
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
94
+ f"<title>{escape(title)}</title>\n<style>\n{fonts}{self.css}"
95
+ "body { margin: 0; padding: 0.25in 0; background: #d9d9d9; }\n"
96
+ f"</style>\n</head>\n<body>\n{self.html}\n</body>\n</html>\n"
97
+ )
98
+
99
+
100
+ def font_face_css(font_url: str, extension: str = "woff2") -> str:
101
+ """``@font-face`` rules for :data:`FONT_FILES`, served from the directory or URL prefix ``font_url``."""
102
+ base = font_url if font_url.endswith("/") or not font_url else font_url + "/"
103
+ formats = {"woff2": "woff2", "woff": "woff", "otf": "opentype", "ttf": "truetype"}
104
+ out = []
105
+ for family, weight, style, name in FONT_FILES:
106
+ url = str(escape(f"{base}{name}.{extension}"))
107
+ unicode_range = f" unicode-range: {FONT_RANGES[name]};" if name in FONT_RANGES else ""
108
+ out.append(
109
+ f'@font-face {{ font-family: "{family}"; font-weight: {weight}; font-style: {style}; '
110
+ f'font-display: swap; src: url("{url}") format("{formats.get(extension, extension)}");{unicode_range} }}\n'
111
+ )
112
+ return "".join(out)
113
+
114
+
115
+ def stylesheet() -> str:
116
+ """The preview stylesheet."""
117
+ global _css
118
+ if _css is None:
119
+ _css = (TEMPLATE_DIR / "cv.css").read_text(encoding="utf-8")
120
+ return _css
121
+
122
+
123
+ def _environment() -> jinja2.Environment:
124
+ global _env
125
+ if _env is None:
126
+ _env = jinja2.Environment(
127
+ loader=jinja2.PackageLoader("cv_tools", "templates/html"),
128
+ autoescape=True,
129
+ undefined=jinja2.StrictUndefined,
130
+ trim_blocks=True,
131
+ lstrip_blocks=True,
132
+ )
133
+ _env.globals.update(ICON_LABELS=ICON_LABELS, field=_field_html, SP=Markup(SP), HFILL=HFILL)
134
+ return _env
135
+
136
+
137
+ def render(doc: Document, today: Optional[_dt.date] = None) -> HtmlResult:
138
+ """Render the preview. ``today`` is the date printed for ``date: auto``."""
139
+ view = _View(doc, today or _dt.date.today())
140
+ pages = view.pages()
141
+ html = _environment().get_template("cv.html.jinja").render(
142
+ source_format=doc.source_format,
143
+ editable=view.editable,
144
+ name=view.field(doc.name, doc.paths.get("name")),
145
+ contact=[view.contact_line(line) for line in doc.contact],
146
+ pages=pages,
147
+ )
148
+ return HtmlResult(html, stylesheet(), len(pages), view.editable)
149
+
150
+
151
+ def auto_date_html(day: _dt.date) -> str:
152
+ """``\\customdate\\today`` of resume.cls: "14<sup>th</sup> November 2023"."""
153
+ n = day.day
154
+ suffix = "th" if 11 <= n % 100 <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
155
+ return f"{n}<sup>{suffix}</sup> {_MONTHS[day.month - 1]} {day.year}"
156
+
157
+
158
+ def dotted(path: Optional[YamlPath]) -> Optional[str]:
159
+ """``("sections", 2, "title")`` -> ``"sections.2.title"``."""
160
+ return None if path is None else ".".join(str(p) for p in path)
161
+
162
+
163
+ def split_path(path: str) -> tuple[Union[str, int], ...]:
164
+ """``"sections.2.title"`` -> ``("sections", 2, "title")``; v2 keys are words, so digits are an index."""
165
+ return tuple(int(p) if p.isdigit() else p for p in path.split(".")) if path else ()
166
+
167
+
168
+ def _field_html(tag: str, cls: str, v: Optional[dict]) -> Markup:
169
+ if v is None:
170
+ return Markup("")
171
+ return Markup(f'<{tag} class="{escape(cls)}"{v["attrs"]}>{v["html"]}</{tag}>')
172
+
173
+
174
+ # A two-sided line ("left \hfill right") is justified text whose only
175
+ # stretchable space is span.cv-hfill; every other space is a rigid span.cv-sp.
176
+
177
+ SP = '<span class="cv-sp"> </span>'
178
+ HFILL = Markup('<span class="cv-hfill"> </span>')
179
+ _TEXT_SPACES = re.compile(r'(<code class="cv-latex">[^<]*</code>|<[^>]*>)')
180
+
181
+
182
+ @functools.lru_cache(maxsize=4096)
183
+ def _rigid(html: str) -> str:
184
+ if " " not in html:
185
+ return html
186
+ parts = _TEXT_SPACES.split(html)
187
+ for i in range(0, len(parts), 2):
188
+ if " " in parts[i]:
189
+ parts[i] = parts[i].replace(" ", SP)
190
+ return "".join(parts)
191
+
192
+
193
+ def _value_attrs(path: Optional[str], raw: Optional[Markup], latex: bool,
194
+ via: Optional[str], anchor: Optional[str]) -> Markup:
195
+ # Setting a value by path through an alias would change the anchored
196
+ # original, so such a value gets data-via-alias instead (web-spec 4.3).
197
+ if via is not None:
198
+ return Markup(f' data-via-alias="{escape(via)}"')
199
+ if path is None:
200
+ return Markup("")
201
+ out = f' data-path="{escape(path)}" data-raw="{raw}"' + (" data-latex" if latex else "")
202
+ if anchor is not None:
203
+ out += f' data-anchor="{escape(anchor)}"'
204
+ return Markup(out)
205
+
206
+
207
+ def _raw(value: Text) -> str:
208
+ return value.latex if isinstance(value, Latex) else str(value)
209
+
210
+
211
+ def _attr(text: str) -> Markup:
212
+ # HTML reads a raw carriage return in an attribute back as a line feed.
213
+ return Markup(str(escape(text)).replace("\r", "&#13;"))
214
+
215
+
216
+ class _View:
217
+
218
+ def __init__(self, doc: Document, today: _dt.date):
219
+ self.doc = doc
220
+ self.editable = doc.source_format == "v2"
221
+ self.decode = doc.source_format == "v1"
222
+ self.date_html = self._date(doc, today)
223
+
224
+ def html(self, value: Text, date: Optional[str] = None) -> str:
225
+ if self.decode and isinstance(value, Latex):
226
+ return latex_text_html(value.latex)
227
+ return _to_html(value, date=date)
228
+
229
+ def field(self, value: Optional[Text], path: Optional[YamlPath], date: Optional[str] = None,
230
+ rigid: bool = False) -> Optional[dict]:
231
+ if value is None:
232
+ return None
233
+ html = self.html(value, date=date)
234
+ if rigid:
235
+ html = _rigid(html)
236
+ latex = isinstance(value, Latex) and not self.decode
237
+ out: dict[str, Any] = {"html": Markup(html), "path": None, "latex": latex, "attrs": Markup("")}
238
+ if self.editable and path is not None:
239
+ source = path + ("latex",) if latex else path
240
+ via = self.doc.aliases.get(source)
241
+ if via is None:
242
+ out["path"] = dotted(source)
243
+ out["attrs"] = _value_attrs(out["path"], _attr(_raw(value)), latex, via, self.doc.anchors.get(source))
244
+ return out
245
+
246
+ def where(self, name: str, path: Optional[YamlPath]) -> Markup:
247
+ # An item that is an alias (- *x) can be moved, so it keeps its path;
248
+ # an item inside aliased content has none of its own.
249
+ if not self.editable or path is None:
250
+ return Markup("")
251
+ aliases = self.doc.aliases
252
+ if path[:-1] in aliases:
253
+ return Markup(f' data-via-alias="{escape(aliases[path[:-1]])}"')
254
+ out = f' {name}="{escape(dotted(path))}"'
255
+ if path in aliases:
256
+ out += f' data-via-alias="{escape(aliases[path])}"'
257
+ elif path in self.doc.anchors:
258
+ out += f' data-anchor="{escape(self.doc.anchors[path])}"'
259
+ return Markup(out)
260
+
261
+ def _date(self, doc: Document, today: _dt.date) -> str:
262
+ if doc.date == "auto":
263
+ inner = auto_date_html(today)
264
+ else:
265
+ inner = self.html(doc.date)
266
+ v = self.field(doc.date, doc.paths.get("date")) if doc.date != "auto" or "date" in doc.paths else None
267
+ return f'<span class="cv-date-value"{v["attrs"] if v else ""}>{inner}</span>'
268
+
269
+ def contact_line(self, line: ContactLine) -> dict:
270
+ items = []
271
+ for i, item in enumerate(line.items):
272
+ joiner = None
273
+ if i:
274
+ if item.kind in ICON_KINDS:
275
+ joiner = "space"
276
+ else:
277
+ joiner = self.field(line.separator, line.separator_path) or {}
278
+ items.append({"joiner": joiner, **self.contact_item(item)})
279
+ return {"where": self.where("data-contact", line.path), "items": items}
280
+
281
+ def contact_item(self, item: ContactItem) -> dict:
282
+ if item.kind == "text":
283
+ return {"kind": "text", "field": self.field(item.value, item.path, date=self.date_html)}
284
+ value = item.value
285
+ href = None
286
+ address: Optional[str] = None
287
+ if not isinstance(value, Latex):
288
+ address = str(value)
289
+ elif self.decode:
290
+ address = latex_url(value.latex)
291
+ if address is not None:
292
+ href = safe_url("https://orcid.org/" + address if item.kind == "orcid" else address)
293
+ field = self.field(value, item.path)
294
+ if item.kind == "url" and address is not None:
295
+ field["html"] = Markup(escape(address))
296
+ return {"kind": item.kind, "field": field, "href": href}
297
+
298
+ def pages(self) -> list[dict]:
299
+ # TeX ignores \newpage on an empty page.
300
+ pages: list[dict] = [{"where": Markup(""), "sections": []}]
301
+ for section in self.doc.sections:
302
+ if section.layout == "newpage":
303
+ if len(pages) == 1 or pages[-1]["sections"]:
304
+ pages.append({"where": self.where("data-newpage", section.path), "sections": []})
305
+ continue
306
+ pages[-1]["sections"].append(self.section(section))
307
+ if len(pages) > 1 and not pages[-1]["sections"]:
308
+ pages.pop()
309
+ return pages
310
+
311
+ def section(self, section: Section) -> dict:
312
+ entries = [self.entry(section.layout, e) for e in section.entries]
313
+ return {
314
+ "layout": section.layout,
315
+ "title_class": "cv-section-title" + _title_depth(section.title),
316
+ "where": self.where("data-section", section.path),
317
+ "title": self.field(section.title or "", section.paths.get("title")),
318
+ "description": self.field(section.description, section.paths.get("description")),
319
+ "entries": entries,
320
+ "citations": [citation_html(p) for p in section.publications],
321
+ }
322
+
323
+ def entry(self, layout: str, e: Entry) -> dict:
324
+ def f(name: str, rigid: bool = False) -> Optional[dict]:
325
+ return self.field(getattr(e, name), e.path_of(name), rigid=rigid)
326
+
327
+ out: dict[str, Any] = {"where": self.where("data-entry", e.path)}
328
+ if layout in ("entries", "entries-inline"):
329
+ out.update(
330
+ title=f("title", True), location=f("location", True), date=f("date", True),
331
+ subtitle=f("subtitle", True), note=f("note", True),
332
+ details=[self.field(d, e.path_of("details", i)) for i, d in enumerate(e.details)],
333
+ bullets=[self.field(b, e.path_of("bullets", i)) for i, b in enumerate(e.bullets)],
334
+ )
335
+ # Without a subtitle TeX drops the \hfill at the line start, so the note is on the left.
336
+ has_subtitle = bool(out["subtitle"] and str(out["subtitle"]["html"]).strip())
337
+ out["note_left"] = not has_subtitle
338
+ out["show_details"] = layout == "entries-inline" or not (
339
+ len(e.details) == 0 or (len(e.details) == 1 and _raw(e.details[0]) == "")
340
+ )
341
+ elif layout == "list":
342
+ out.update(text=f("text"), bullets=[self.field(b, e.path_of("bullets", i)) for i, b in enumerate(e.bullets)])
343
+ elif layout == "pairs":
344
+ out.update(title=f("title", True), text=f("text", True))
345
+ elif layout == "dated":
346
+ out.update(text=f("text"), date=f("date"))
347
+ if out["date"] is not None:
348
+ if self.decode and isinstance(e.date, Latex):
349
+ month, year = _latex_split_html(e.date.latex)
350
+ else:
351
+ month, year = split_date_html(e.date)
352
+ out["date"]["month"], out["date"]["year"] = Markup(month), Markup(year)
353
+ else: # text
354
+ out.update(text=f("text"))
355
+ return out
356
+
357
+
358
+ # Characters whose depth moves the rule under an upper-case title down.
359
+ _DEPTH_2 = frozenset("/()[]{}|\\")
360
+ _DEPTH_1 = frozenset(",;Q")
361
+
362
+
363
+ def _title_depth(title: Optional[Text]) -> str:
364
+ if title is None or isinstance(title, Latex):
365
+ return ""
366
+ chars = set(str(title).upper())
367
+ if chars & _DEPTH_2:
368
+ return " cv-depth-2"
369
+ if chars & _DEPTH_1:
370
+ return " cv-depth-1"
371
+ return ""
372
+
373
+
374
+ _LATEX_SYNTAX = re.compile(r"[\\{}~$]")
375
+ _STYLE_COMMANDS = re.compile(r"\\(?:emph|textit|textbf|textsc|textrm|textsf|texttt|textup|textmd|mbox|text)\s*(?=\{)")
376
+ # Text commands latexcodec leaves alone; a private-use placeholder keeps a backslash from later steps.
377
+ _BACKSLASH = "\ue000"
378
+ _TEXT_COMMANDS = {
379
+ "textbackslash": _BACKSLASH, "textless": "<", "textgreater": ">", "textbar": "|",
380
+ "textasciicircum": "ˆ", "textasciitilde": "˜", "LaTeX": "LaTeX", "TeX": "TeX",
381
+ }
382
+ _TEXT_COMMAND = re.compile(r"\\ensuremath\s*\{\\sim\}|\\(" + "|".join(_TEXT_COMMANDS) + r")(?![a-zA-Z])\s*(?:\{\})?")
383
+
384
+
385
+ def _text_command(m: re.Match) -> str:
386
+ return "∼" if m.group(1) is None else _TEXT_COMMANDS[m.group(1)]
387
+
388
+
389
+ # Accents latexcodec leaves alone (it knows only precomposed characters).
390
+ _ACCENTS = {
391
+ "~": ("\u0303", "˜"), "^": ("\u0302", "ˆ"), "'": ("\u0301", "´"), "`": ("\u0300", "`"),
392
+ '"': ("\u0308", "¨"), "=": ("\u0304", "¯"), ".": ("\u0307", "˙"), "u": ("\u0306", "˘"),
393
+ "v": ("\u030c", "ˇ"), "H": ("\u030b", "˝"), "c": ("\u0327", "¸"), "d": ("\u0323", "."),
394
+ "b": ("\u0331", "ˍ"), "r": ("\u030a", "˚"), "k": ("\u0328", "˛"),
395
+ }
396
+ _ACCENT = re.compile(r"""(?<!\\)\\(?:([~^'`"=.])\s*(?:\{([^{}\\]?)\}|([^\s{}\\]))|([uvHcdbrk])\s*\{([^{}\\]?)\})""")
397
+
398
+
399
+ def _accent(m: re.Match) -> str:
400
+ accent = m.group(1) or m.group(4)
401
+ base = m.group(2) if m.group(2) is not None else m.group(3) if m.group(3) is not None else m.group(5)
402
+ mark, alone = _ACCENTS[accent]
403
+ return unicodedata.normalize("NFC", base + mark) if base else alone
404
+
405
+
406
+ def bib_text(latex: str) -> str:
407
+ """A BibTeX field as the plain text pdflatex prints, roughly."""
408
+ if not _LATEX_SYNTAX.search(latex):
409
+ return latex
410
+ text = _TEXT_COMMAND.sub(_text_command, latex)
411
+ try:
412
+ text = codecs.decode(text, "ulatex")
413
+ except Exception: # pragma: no cover - latexcodec passes unknown input through
414
+ pass
415
+ text = _ACCENT.sub(_accent, text)
416
+ text = _STYLE_COMMANDS.sub("", text)
417
+ text = re.sub(r"(?<!\\)[{}]", "", text)
418
+ return re.sub(r"\\([{}%&#$_])", r"\1", text).replace(_BACKSLASH, "\\")
419
+
420
+
421
+ def _bib_html(latex: str) -> str:
422
+ return escape_html(tex_ligatures_text(bib_text(latex)), quote=False)
423
+
424
+
425
+ # v1 values are LaTeX. Bold, italic, links, line breaks and \quad keep their
426
+ # structure; the rest is decoded like a BibTeX field.
427
+ _V1_COMMAND = re.compile(r"\\(?:(textbf|textit|emph|href|url|quad|newline)(?![a-zA-Z])\s*|(\\)|( ))")
428
+ _V1_FALLBACK = re.compile(r"\\(?:textbf|textit|emph|url)(?![a-zA-Z])|\\href\s*\{(?:[^{}\\]|\\.)*\}")
429
+ _URL_ESCAPE = re.compile(r"\\([%#&_$~{}])")
430
+ _COMMENT = re.compile(r"%[^\n]*(?:\n[ \t]*)?")
431
+
432
+
433
+ @functools.lru_cache(maxsize=4096)
434
+ def latex_text_html(latex: str) -> str:
435
+ """LaTeX (a v1 text value) as the HTML of the text it prints."""
436
+ return _latex_text_html(latex, 0)
437
+
438
+
439
+ def _latex_text_html(latex: str, depth: int) -> str:
440
+ if depth > MAX_NESTING:
441
+ return _bib_html(_V1_FALLBACK.sub("", latex))
442
+ out: list[str] = []
443
+ text: list[str] = []
444
+
445
+ def flush() -> None:
446
+ if text:
447
+ out.append(_bib_html("".join(text)))
448
+ text.clear()
449
+
450
+ i, n = 0, len(latex)
451
+ while i < n:
452
+ if latex[i] == "%":
453
+ i = _COMMENT.match(latex, i).end() # type: ignore[union-attr]
454
+ continue
455
+ m = _V1_COMMAND.match(latex, i) if latex[i] == "\\" else None
456
+ if m is None:
457
+ if latex[i] == "\\": # another command: decoded with the text
458
+ text.append(latex[i : i + 2])
459
+ i += 2
460
+ else:
461
+ text.append(latex[i])
462
+ i += 1
463
+ continue
464
+ name = m.group(1)
465
+ if m.group(2) or name == "newline":
466
+ flush()
467
+ out.append("<br>")
468
+ i = _line_break_args(latex, m.end()) if m.group(2) else m.end()
469
+ continue
470
+ if m.group(3):
471
+ text.append(" ")
472
+ i = m.end()
473
+ continue
474
+ if name == "quad":
475
+ flush()
476
+ out.append('<span class="cv-quad"></span>')
477
+ i = m.end()
478
+ continue
479
+ args, end = _latex_args(latex, m.end(), 2 if name == "href" else 1)
480
+ if args is None: # no braced argument: print the command as text
481
+ text.append(latex[i : m.end()])
482
+ i = m.end()
483
+ continue
484
+ flush()
485
+ if name == "textbf":
486
+ out.append("<strong>" + _latex_text_html(args[0], depth + 1) + "</strong>")
487
+ elif name in ("textit", "emph"):
488
+ out.append("<em>" + _latex_text_html(args[0], depth + 1) + "</em>")
489
+ else:
490
+ target = _URL_ESCAPE.sub(r"\1", args[0])
491
+ inner = _latex_text_html(args[1], depth + 1) if name == "href" else escape_html(target, quote=False)
492
+ href = safe_url(target)
493
+ if href is None:
494
+ out.append('<span class="cv-link">' + inner + "</span>")
495
+ else:
496
+ out.append(_split_link('<a class="cv-link" href="' + escape_html(href) + '">', inner))
497
+ i = end
498
+ flush()
499
+ return "".join(out)
500
+
501
+
502
+ def _line_break_args(latex: str, i: int) -> int:
503
+ # \\ also reads a star and an optional argument.
504
+ j = i
505
+ while j < len(latex) and latex[j] == " ":
506
+ j += 1
507
+ if j < len(latex) and latex[j] == "*":
508
+ i = j = j + 1
509
+ while j < len(latex) and latex[j] == " ":
510
+ j += 1
511
+ if j < len(latex) and latex[j] == "[":
512
+ close = latex.find("]", j)
513
+ if close != -1:
514
+ return close + 1
515
+ return i
516
+
517
+
518
+ def latex_url(latex: str) -> str:
519
+ """A URL in v1 LaTeX, without its escapes."""
520
+ return _URL_ESCAPE.sub(r"\1", latex)
521
+
522
+
523
+ def _latex_args(latex: str, i: int, count: int) -> tuple[Optional[list[str]], int]:
524
+ args = []
525
+ for _ in range(count):
526
+ while i < len(latex) and latex[i] == " ":
527
+ i += 1
528
+ if i >= len(latex) or latex[i] != "{":
529
+ return None, i
530
+ depth, j = 0, i
531
+ while j < len(latex):
532
+ c = latex[j]
533
+ if c == "\\":
534
+ j += 2
535
+ continue
536
+ if c == "{":
537
+ depth += 1
538
+ elif c == "}":
539
+ depth -= 1
540
+ if depth == 0:
541
+ break
542
+ j += 1
543
+ if j >= len(latex):
544
+ return None, i
545
+ args.append(latex[i + 1 : j])
546
+ i = j + 1
547
+ return args, i
548
+
549
+
550
+ def _latex_split_html(latex: str) -> tuple[str, str]:
551
+ cut = _last_split_space(latex)
552
+ if cut == -1:
553
+ return ("", latex_text_html(latex))
554
+ return (latex_text_html(latex[:cut]), latex_text_html(latex[cut + 1 :]))
555
+
556
+
557
+ @functools.lru_cache(maxsize=1024)
558
+ def citation_html(pub: Publication) -> Markup:
559
+ """One publication, laid out as :func:`cv_tools.render.latex.citation` does."""
560
+ names = [f"<strong>{_bib_html(a.name)}</strong>" if a.highlighted else _bib_html(a.name) for a in pub.authors]
561
+ authors = ", ".join(names[:-1]) + " and " + names[-1] if len(names) > 1 else "".join(names)
562
+ parts = [
563
+ authors,
564
+ f"<em>{_bib_html(pub.title)}</em>" if pub.title is not None else "",
565
+ _bib_html(pub.venue) if pub.venue else "",
566
+ _bib_html(pub.year) if pub.year else "",
567
+ ]
568
+ text = ", ".join(p for p in parts if p)
569
+ if pub.url:
570
+ href = safe_url(pub.url)
571
+ if href is not None:
572
+ text += f' <a class="cv-link" href="{escape(href)}">[Link]</a>'
573
+ else:
574
+ text += ' <span class="cv-link">[Link]</span>'
575
+ return Markup(text + ".")
576
+
577
+
578
+ __all__ = [
579
+ "FONT_FILES", "FONT_RANGES", "HtmlResult", "auto_date_html", "bib_text", "citation_html", "dotted", "font_face_css",
580
+ "latex_html", "latex_text_html", "render", "split_path", "stylesheet",
581
+ ]