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/render/html.py
ADDED
|
@@ -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", " "))
|
|
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
|
+
]
|