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/__init__.py
ADDED
cv_tools/__main__.py
ADDED
cv_tools/api.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The public API: load a CV, then render it to LaTeX, HTML or PDF."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import sys
|
|
7
|
+
import warnings
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, Union
|
|
10
|
+
|
|
11
|
+
from . import engine as _engine
|
|
12
|
+
from . import charset, model, v1
|
|
13
|
+
from .loader import CVError, Source, YamlPath, load_file, load_text
|
|
14
|
+
from .migrate import migrate_text as _migrate
|
|
15
|
+
from .model import Document
|
|
16
|
+
from .render import html, latex
|
|
17
|
+
from .render.html import HtmlResult
|
|
18
|
+
from .render.latex import RenderResult
|
|
19
|
+
from .validate import ValidationError, validate
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"CVError", "Document", "HtmlResult", "RenderResult", "ValidationError",
|
|
23
|
+
"build_pdf", "load", "load_with_v1_warnings", "loads", "migrate_text", "render_html", "render_tex",
|
|
24
|
+
"split_path",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load(path: Union[str, Path]) -> Document:
|
|
29
|
+
"""Load a v1 or v2 CV file into the v2 model (a v1 file also warns on stderr)."""
|
|
30
|
+
return _document(load_file(path))[0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_with_v1_warnings(path: Union[str, Path]) -> tuple[Document, list[str]]:
|
|
34
|
+
"""Like :func:`load`, and also return the v1 conversion warnings on their own."""
|
|
35
|
+
return _document(load_file(path))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def loads(text: str, name: str = "<string>", base_dir: Optional[Path] = None) -> Document:
|
|
39
|
+
"""Like :func:`load`, for YAML text. Relative .bib paths resolve against ``base_dir``."""
|
|
40
|
+
return _document(load_text(text, name, base_dir))[0]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _document(src: Source) -> tuple[Document, list[str]]:
|
|
44
|
+
path = Path(src.name) if src.name != "<string>" else None
|
|
45
|
+
if v1.detect(src) == "v2":
|
|
46
|
+
errors = validate(src)
|
|
47
|
+
if errors:
|
|
48
|
+
raise ValidationError(errors)
|
|
49
|
+
doc = _checked(src, src.data, "v2", path, lambda p: p)
|
|
50
|
+
doc.aliases, doc.anchors = src.aliases, src.anchors
|
|
51
|
+
return doc, []
|
|
52
|
+
|
|
53
|
+
message = v1.DEPRECATION.format(file=src.name)
|
|
54
|
+
print(message, file=sys.stderr)
|
|
55
|
+
warnings.warn(message, DeprecationWarning, stacklevel=3)
|
|
56
|
+
conversion = v1.convert(src)
|
|
57
|
+
doc = _checked(src, conversion.data, "v1", path, lambda p: _v1_path(conversion.origins, p))
|
|
58
|
+
doc.warnings[:0] = conversion.warnings
|
|
59
|
+
return doc, list(conversion.warnings)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _checked(src: Source, data: dict, fmt: str, path: Optional[Path], source_path) -> Document:
|
|
63
|
+
# pdflatex must be able to print every character of the text and .bib fields.
|
|
64
|
+
errors = charset.check_data(src, data, source_path, show_path=fmt == "v2")
|
|
65
|
+
if errors:
|
|
66
|
+
raise ValidationError(errors)
|
|
67
|
+
doc = _from_data(src, data, fmt, path, source_path)
|
|
68
|
+
errors = [e for s in doc.sections if s.bib is not None for e in charset.check_publications(s.bib, s.publications)]
|
|
69
|
+
if errors:
|
|
70
|
+
raise ValidationError(errors)
|
|
71
|
+
return doc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _v1_path(origins: dict[YamlPath, YamlPath], path: YamlPath) -> YamlPath:
|
|
75
|
+
while path and path not in origins:
|
|
76
|
+
path = path[:-1]
|
|
77
|
+
return origins.get(path, ())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _from_data(src: Source, data: dict, fmt: str, path: Optional[Path], source_path) -> Document:
|
|
81
|
+
try:
|
|
82
|
+
return model.from_data(data, src.base_dir, fmt, path)
|
|
83
|
+
except model.ModelError as e:
|
|
84
|
+
raise src.error(e.message, source_path(e.path)) from None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_tex(doc: Document) -> RenderResult:
|
|
88
|
+
"""Render the LaTeX source and the support files it needs."""
|
|
89
|
+
return latex.render(doc)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def render_html(doc: Document, today: Optional[datetime.date] = None) -> HtmlResult:
|
|
93
|
+
"""Render the HTML live preview. ``today`` is the date printed for ``date: auto``."""
|
|
94
|
+
return html.render(doc, today=today)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def split_path(path: str) -> tuple[Union[str, int], ...]:
|
|
98
|
+
"""A ``data-path`` value as a YAML path."""
|
|
99
|
+
return html.split_path(path)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_pdf(
|
|
103
|
+
doc: Document,
|
|
104
|
+
out: Union[str, Path, None] = None,
|
|
105
|
+
engine: str = "auto",
|
|
106
|
+
source_date_epoch: Optional[int] = None,
|
|
107
|
+
) -> bytes:
|
|
108
|
+
"""Compile the CV to PDF with ``engine`` (auto, pdflatex or docker), write ``out`` if given, and return the bytes."""
|
|
109
|
+
workdir = Path(out).resolve().parent if out is not None else Path.cwd()
|
|
110
|
+
pdf = _engine.compile_pdf(render_tex(doc), engine=engine, source_date_epoch=source_date_epoch, workdir=workdir)
|
|
111
|
+
if out is not None:
|
|
112
|
+
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
Path(out).write_bytes(pdf)
|
|
114
|
+
return pdf
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def migrate_text(v1_text: str, name: str = "<string>") -> str:
|
|
118
|
+
"""Convert v1 YAML text to v2 YAML text. Warnings go to ``warnings.warn``."""
|
|
119
|
+
migration = _migrate(v1_text, name)
|
|
120
|
+
for warning in migration.warnings:
|
|
121
|
+
warnings.warn(warning, UserWarning, stacklevel=2)
|
|
122
|
+
return migration.text
|
|
123
|
+
|
cv_tools/bib.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""BibTeX loading, author names and highlight matching. Fields are LaTeX, printed as written."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import re
|
|
7
|
+
import unicodedata
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import latexcodec # noqa: F401 (registers the "ulatex" codec; a pybtex dependency)
|
|
12
|
+
|
|
13
|
+
from pybtex.database import Person, parse_file
|
|
14
|
+
from pybtex.exceptions import PybtexError
|
|
15
|
+
|
|
16
|
+
from .loader import CVError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Author:
|
|
21
|
+
name: str # LaTeX, as written in the .bib file
|
|
22
|
+
highlighted: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Publication:
|
|
27
|
+
"""One .bib entry. Text fields are LaTeX from the .bib file; None when missing."""
|
|
28
|
+
|
|
29
|
+
key: str
|
|
30
|
+
authors: tuple[Author, ...]
|
|
31
|
+
title: str | None
|
|
32
|
+
venue: str | None
|
|
33
|
+
year: str | None
|
|
34
|
+
url: str | None # a plain URL, BibTeX escapes resolved
|
|
35
|
+
venue_field: str | None = None # "journal" or "booktitle", where the venue came from
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_name(person: Person) -> str:
|
|
39
|
+
"""The full name with its von and Jr. parts, which cv-tools 1.x dropped."""
|
|
40
|
+
parts = (
|
|
41
|
+
person.first_names + person.middle_names + person.prelast_names + person.last_names + person.lineage_names
|
|
42
|
+
)
|
|
43
|
+
return " ".join(" ".join(parts).split())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def bib_url(url: str) -> str:
|
|
47
|
+
"""A BibTeX ``url`` field as a plain URL, escapes and braces resolved."""
|
|
48
|
+
url = re.sub(r"\\([%#_&$~])", r"\1", url)
|
|
49
|
+
return re.sub(r"(?<!\\)[{}]", "", url).strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _decode(name: str) -> str:
|
|
53
|
+
"""LaTeX accents to Unicode, so ``Jos{\\'e}`` and ``José`` compare equal."""
|
|
54
|
+
try:
|
|
55
|
+
name = codecs.decode(name, "ulatex")
|
|
56
|
+
except (ValueError, LookupError): # pragma: no cover - latexcodec passes unknown input through
|
|
57
|
+
pass
|
|
58
|
+
return unicodedata.normalize("NFC", name)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _normalize(name: str) -> str:
|
|
62
|
+
name = _decode(name).replace("{", "").replace("}", "")
|
|
63
|
+
return " ".join(name.split()).casefold()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _join(*parts: str) -> str:
|
|
67
|
+
return " ".join(p for p in parts if p)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _name_forms(person: Person) -> set[str]:
|
|
71
|
+
first = " ".join(person.first_names + person.middle_names)
|
|
72
|
+
last = _join(*person.prelast_names, *person.last_names)
|
|
73
|
+
jr = " ".join(person.lineage_names)
|
|
74
|
+
forms = {format_name(person), _join(first, last) + (f", {jr}" if jr else "")}
|
|
75
|
+
if first:
|
|
76
|
+
forms.add(f"{last}, {jr}, {first}" if jr else f"{last}, {first}")
|
|
77
|
+
if jr:
|
|
78
|
+
forms.add(f"{last} {jr}, {first}")
|
|
79
|
+
return {_normalize(f) for f in forms}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def matches(person: Person, names: list[str]) -> bool:
|
|
83
|
+
"""True if any of ``names`` is this author's full name, ignoring case, braces, spacing and accent spelling."""
|
|
84
|
+
wanted = {_normalize(n) for n in names} - {""}
|
|
85
|
+
return bool(wanted & _name_forms(person))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _field(fields, name: str) -> str | None:
|
|
89
|
+
value = fields.get(name)
|
|
90
|
+
return value if value is not None and value.strip() else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load(path: Path, highlight: list[str]) -> tuple[list[Publication], list[str]]:
|
|
94
|
+
"""Entries of a .bib file in file order, and warnings about missing fields."""
|
|
95
|
+
if not Path(path).is_file():
|
|
96
|
+
raise CVError(f"BibTeX file not found: {path}")
|
|
97
|
+
try:
|
|
98
|
+
data = parse_file(str(path), bib_format="bibtex")
|
|
99
|
+
except (PybtexError, UnicodeDecodeError) as e:
|
|
100
|
+
raise CVError(f"cannot read BibTeX file {path}: {e}") from None
|
|
101
|
+
|
|
102
|
+
publications, warnings = [], []
|
|
103
|
+
for key, entry in data.entries.items():
|
|
104
|
+
persons = entry.persons.get("author", [])
|
|
105
|
+
authors = tuple(Author(format_name(p), matches(p, highlight)) for p in persons)
|
|
106
|
+
fields = entry.fields
|
|
107
|
+
title, year = _field(fields, "title"), _field(fields, "year")
|
|
108
|
+
missing = [f for f, present in (("author", authors), ("title", title), ("year", year)) if not present]
|
|
109
|
+
for name in missing:
|
|
110
|
+
warnings.append(f"{path}: warning: entry '{key}' has no {name}; it is left out of the citation")
|
|
111
|
+
publications.append(
|
|
112
|
+
Publication(
|
|
113
|
+
key=key,
|
|
114
|
+
authors=authors,
|
|
115
|
+
title=title,
|
|
116
|
+
venue=_field(fields, "journal") or _field(fields, "booktitle"),
|
|
117
|
+
venue_field="journal" if _field(fields, "journal") else "booktitle" if _field(fields, "booktitle") else None,
|
|
118
|
+
year=year,
|
|
119
|
+
url=bib_url(fields["url"]) if _field(fields, "url") else None,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return publications, warnings
|
cv_tools/charset.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Checks that pdflatex can print every character (``templates/latex/unicode.txt``) before it runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Iterator, Optional
|
|
11
|
+
|
|
12
|
+
from .loader import CVError, Source, YamlPath
|
|
13
|
+
from .markup import Latex, to_latex
|
|
14
|
+
|
|
15
|
+
# Keys whose values are never printed as text.
|
|
16
|
+
_NOT_TEXT = frozenset({"version", "layout", "bib", "highlight", "url", "orcid", "linkedin", "github", "twitter"})
|
|
17
|
+
|
|
18
|
+
# hyperref reads URL arguments with other catcodes, so any character works there.
|
|
19
|
+
_URL_COMMAND = re.compile(r"\\(?:href|url)\s*\{")
|
|
20
|
+
|
|
21
|
+
_YAML_LINE_BREAK = re.compile("\r\n|[\n\r\x85\u2028\u2029]")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@lru_cache(maxsize=1)
|
|
25
|
+
def supported() -> frozenset[int]:
|
|
26
|
+
"""Code points above ASCII that print in every font of the class."""
|
|
27
|
+
text = (resources.files("cv_tools") / "templates" / "latex" / "unicode.txt").read_text(encoding="utf-8")
|
|
28
|
+
return frozenset(int(line.split()[0], 16) for line in text.splitlines() if line and not line.startswith("#"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def prints(c: str) -> bool:
|
|
32
|
+
return ord(c) < 0x80 or ord(c) in supported()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _without_urls(latex: str) -> str:
|
|
36
|
+
out, i = [], 0
|
|
37
|
+
for match in _URL_COMMAND.finditer(latex):
|
|
38
|
+
if match.start() < i:
|
|
39
|
+
continue
|
|
40
|
+
depth, j = 1, match.end()
|
|
41
|
+
while j < len(latex) and depth:
|
|
42
|
+
if latex[j] == "\\":
|
|
43
|
+
j += 2
|
|
44
|
+
continue
|
|
45
|
+
depth += {"{": 1, "}": -1}.get(latex[j], 0)
|
|
46
|
+
j += 1
|
|
47
|
+
out.append(latex[i : match.end()])
|
|
48
|
+
i = j - 1 if depth == 0 else len(latex)
|
|
49
|
+
out.append(latex[i:])
|
|
50
|
+
return "".join(out)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def problems(latex: str, uppercased: bool = False) -> list[tuple[str, Optional[str]]]:
|
|
54
|
+
"""``(character, upper-case form)`` for each character of ``latex`` that pdflatex cannot print."""
|
|
55
|
+
# The upper-case form is set when only it fails (titles are upper-cased).
|
|
56
|
+
found: dict[str, Optional[str]] = {}
|
|
57
|
+
for c in _without_urls(latex):
|
|
58
|
+
if c in found:
|
|
59
|
+
continue
|
|
60
|
+
if not prints(c):
|
|
61
|
+
found[c] = None
|
|
62
|
+
elif uppercased and not all(prints(u) for u in c.upper()):
|
|
63
|
+
found[c] = c.upper()
|
|
64
|
+
return list(found.items())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def describe(c: str, upper: Optional[str] = None) -> str:
|
|
68
|
+
name = unicodedata.name(c, "")
|
|
69
|
+
shown = repr(c) if unicodedata.category(c)[0] != "C" else "a control character"
|
|
70
|
+
what = f"the character {shown} (U+{ord(c):04X}{' ' + name if name else ''})"
|
|
71
|
+
if upper is not None:
|
|
72
|
+
return (f"{what} becomes {upper!r} in the upper-cased name or section title, which pdflatex cannot "
|
|
73
|
+
"print with the fonts of the cv-tools class; leave it out of the name and titles")
|
|
74
|
+
return (f"{what} cannot be printed by pdflatex with the fonts of the cv-tools class; "
|
|
75
|
+
"replace it or leave it out")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _texts(value: Any, path: YamlPath) -> Iterator[tuple[YamlPath, str]]:
|
|
79
|
+
if isinstance(value, dict):
|
|
80
|
+
if list(value) == ["latex"] and isinstance(value["latex"], str):
|
|
81
|
+
yield path, value["latex"]
|
|
82
|
+
return
|
|
83
|
+
for key, child in value.items():
|
|
84
|
+
if key not in _NOT_TEXT:
|
|
85
|
+
yield from _texts(child, path + (key,))
|
|
86
|
+
elif isinstance(value, list):
|
|
87
|
+
for i, child in enumerate(value):
|
|
88
|
+
yield from _texts(child, path + (i,))
|
|
89
|
+
elif isinstance(value, str):
|
|
90
|
+
yield path, to_latex(value) # URLs in links come out escaped
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _uppercased(path: YamlPath) -> bool:
|
|
94
|
+
return path == ("name",) or (len(path) == 3 and path[0] == "sections" and path[2] == "title")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _offset(text: str, line: int, col: int) -> int:
|
|
98
|
+
start = 0
|
|
99
|
+
for _ in range(line - 1):
|
|
100
|
+
match = _YAML_LINE_BREAK.search(text, start)
|
|
101
|
+
if match is None:
|
|
102
|
+
return len(text)
|
|
103
|
+
start = match.end()
|
|
104
|
+
return start + col - 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _position(text: str, start: int) -> tuple[int, int]:
|
|
108
|
+
breaks = list(_YAML_LINE_BREAK.finditer(text, 0, start))
|
|
109
|
+
line_start = breaks[-1].end() if breaks else 0
|
|
110
|
+
return len(breaks) + 1, start - line_start + 1
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def check_data(src: Source, data: dict, source_path: Callable[[YamlPath], YamlPath],
|
|
114
|
+
show_path: bool = True) -> list[CVError]:
|
|
115
|
+
"""An error for each character of a text field that pdflatex cannot print."""
|
|
116
|
+
from .validate import format_path
|
|
117
|
+
|
|
118
|
+
errors = []
|
|
119
|
+
for path, latex in _texts(data, ()):
|
|
120
|
+
found = problems(latex, _uppercased(path))
|
|
121
|
+
if not found:
|
|
122
|
+
continue
|
|
123
|
+
where = src.position(source_path(path))
|
|
124
|
+
start = _offset(src.text, *where) if where else 0
|
|
125
|
+
for c, upper in found:
|
|
126
|
+
index = src.text.find(c, start)
|
|
127
|
+
line, col = _position(src.text, index) if index != -1 else (where or (None, None))
|
|
128
|
+
prefix = f"{format_path(path)}: " if show_path else ""
|
|
129
|
+
errors.append(CVError(prefix + describe(c, upper), src.name, line, col))
|
|
130
|
+
return sorted(errors, key=lambda e: (e.line or 0, e.col or 0))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_publications(bib_path: Path, publications) -> list[CVError]:
|
|
134
|
+
"""An error for each character of a used .bib field that pdflatex cannot print."""
|
|
135
|
+
errors = []
|
|
136
|
+
for pub in publications:
|
|
137
|
+
fields = [("author", a.name) for a in pub.authors]
|
|
138
|
+
fields += [("title", pub.title), (pub.venue_field, pub.venue), ("year", pub.year)]
|
|
139
|
+
for field, value in fields:
|
|
140
|
+
for c, _ in problems(value or ""):
|
|
141
|
+
errors.append(CVError(f"entry '{pub.key}', field '{field}': {describe(c)}", str(bib_path)))
|
|
142
|
+
return errors
|