simple-resume 0.1.9__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.
- simple_resume/__init__.py +132 -0
- simple_resume/core/__init__.py +47 -0
- simple_resume/core/colors.py +215 -0
- simple_resume/core/config.py +672 -0
- simple_resume/core/constants/__init__.py +207 -0
- simple_resume/core/constants/colors.py +98 -0
- simple_resume/core/constants/files.py +28 -0
- simple_resume/core/constants/layout.py +58 -0
- simple_resume/core/dependencies.py +258 -0
- simple_resume/core/effects.py +154 -0
- simple_resume/core/exceptions.py +261 -0
- simple_resume/core/file_operations.py +68 -0
- simple_resume/core/generate/__init__.py +21 -0
- simple_resume/core/generate/exceptions.py +69 -0
- simple_resume/core/generate/html.py +233 -0
- simple_resume/core/generate/pdf.py +659 -0
- simple_resume/core/generate/plan.py +131 -0
- simple_resume/core/hydration.py +55 -0
- simple_resume/core/importers/__init__.py +3 -0
- simple_resume/core/importers/json_resume.py +284 -0
- simple_resume/core/latex/__init__.py +60 -0
- simple_resume/core/latex/context.py +56 -0
- simple_resume/core/latex/conversion.py +227 -0
- simple_resume/core/latex/escaping.py +68 -0
- simple_resume/core/latex/fonts.py +93 -0
- simple_resume/core/latex/formatting.py +81 -0
- simple_resume/core/latex/sections.py +218 -0
- simple_resume/core/latex/types.py +84 -0
- simple_resume/core/markdown.py +127 -0
- simple_resume/core/models.py +102 -0
- simple_resume/core/palettes/__init__.py +38 -0
- simple_resume/core/palettes/common.py +73 -0
- simple_resume/core/palettes/data/default_palettes.json +58 -0
- simple_resume/core/palettes/exceptions.py +33 -0
- simple_resume/core/palettes/fetch_types.py +52 -0
- simple_resume/core/palettes/generators.py +137 -0
- simple_resume/core/palettes/registry.py +76 -0
- simple_resume/core/palettes/resolution.py +123 -0
- simple_resume/core/palettes/sources.py +162 -0
- simple_resume/core/paths.py +21 -0
- simple_resume/core/protocols.py +134 -0
- simple_resume/core/py.typed +0 -0
- simple_resume/core/render/__init__.py +37 -0
- simple_resume/core/render/manage.py +199 -0
- simple_resume/core/render/plan.py +405 -0
- simple_resume/core/result.py +226 -0
- simple_resume/core/resume.py +609 -0
- simple_resume/core/skills.py +60 -0
- simple_resume/core/validation.py +321 -0
- simple_resume/py.typed +0 -0
- simple_resume/shell/__init__.py +3 -0
- simple_resume/shell/assets/static/css/README.md +213 -0
- simple_resume/shell/assets/static/css/common.css +641 -0
- simple_resume/shell/assets/static/css/fonts.css +42 -0
- simple_resume/shell/assets/static/css/preview.css +82 -0
- simple_resume/shell/assets/static/css/print.css +99 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Book.otf +0 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Light.otf +0 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Medium.otf +0 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Oblique.otf +0 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Roman.otf +0 -0
- simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Brands-Regular-400.otf +0 -0
- simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Free-Solid-900.otf +0 -0
- simple_resume/shell/assets/static/images/default_profile_1.jpg +0 -0
- simple_resume/shell/assets/static/images/default_profile_2.png +0 -0
- simple_resume/shell/assets/static/schema.json +236 -0
- simple_resume/shell/assets/static/themes/README.md +208 -0
- simple_resume/shell/assets/static/themes/bold.yaml +64 -0
- simple_resume/shell/assets/static/themes/classic.yaml +64 -0
- simple_resume/shell/assets/static/themes/executive.yaml +64 -0
- simple_resume/shell/assets/static/themes/minimal.yaml +64 -0
- simple_resume/shell/assets/static/themes/modern.yaml +64 -0
- simple_resume/shell/assets/templates/html/cover.html +129 -0
- simple_resume/shell/assets/templates/html/demo.html +13 -0
- simple_resume/shell/assets/templates/html/resume_base.html +453 -0
- simple_resume/shell/assets/templates/html/resume_no_bars.html +316 -0
- simple_resume/shell/assets/templates/html/resume_with_bars.html +362 -0
- simple_resume/shell/cli/__init__.py +35 -0
- simple_resume/shell/cli/main.py +975 -0
- simple_resume/shell/cli/palette.py +75 -0
- simple_resume/shell/cli/random_palette_demo.py +407 -0
- simple_resume/shell/config.py +96 -0
- simple_resume/shell/effect_executor.py +211 -0
- simple_resume/shell/file_opener.py +308 -0
- simple_resume/shell/generate/__init__.py +37 -0
- simple_resume/shell/generate/core.py +650 -0
- simple_resume/shell/generate/lazy.py +284 -0
- simple_resume/shell/io_utils.py +199 -0
- simple_resume/shell/palettes/__init__.py +1 -0
- simple_resume/shell/palettes/fetch.py +63 -0
- simple_resume/shell/palettes/loader.py +321 -0
- simple_resume/shell/palettes/remote.py +179 -0
- simple_resume/shell/pdf_executor.py +52 -0
- simple_resume/shell/py.typed +0 -0
- simple_resume/shell/render/__init__.py +1 -0
- simple_resume/shell/render/latex.py +308 -0
- simple_resume/shell/render/operations.py +240 -0
- simple_resume/shell/resume_extensions.py +737 -0
- simple_resume/shell/runtime/__init__.py +7 -0
- simple_resume/shell/runtime/content.py +190 -0
- simple_resume/shell/runtime/generate.py +497 -0
- simple_resume/shell/runtime/lazy.py +138 -0
- simple_resume/shell/runtime/lazy_import.py +173 -0
- simple_resume/shell/service_locator.py +80 -0
- simple_resume/shell/services.py +256 -0
- simple_resume/shell/session/__init__.py +6 -0
- simple_resume/shell/session/config.py +35 -0
- simple_resume/shell/session/manage.py +386 -0
- simple_resume/shell/strategies.py +181 -0
- simple_resume/shell/themes/__init__.py +35 -0
- simple_resume/shell/themes/loader.py +230 -0
- simple_resume-0.1.9.dist-info/METADATA +201 -0
- simple_resume-0.1.9.dist-info/RECORD +116 -0
- simple_resume-0.1.9.dist-info/WHEEL +4 -0
- simple_resume-0.1.9.dist-info/entry_points.txt +5 -0
- simple_resume-0.1.9.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Markdown to LaTeX conversion functions (pure, deterministic)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from simple_resume.core.latex.escaping import escape_latex, escape_url
|
|
10
|
+
from simple_resume.core.latex.types import Block
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _InlineConverter:
|
|
14
|
+
"""Convert limited Markdown inline formatting to LaTeX.
|
|
15
|
+
|
|
16
|
+
This class is pure and deterministic - it uses internal state only for
|
|
17
|
+
placeholder generation during conversion, which is deterministic based
|
|
18
|
+
on the order of replacements.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._placeholders: dict[str, str] = {}
|
|
23
|
+
self._counter = itertools.count()
|
|
24
|
+
|
|
25
|
+
def convert(self, text: str) -> str:
|
|
26
|
+
"""Return a string that is safe for LaTeX.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
text: Markdown-formatted text.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
LaTeX-formatted text with escaping applied.
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
working = text
|
|
36
|
+
working = re.sub(r"`([^`]+)`", self._code_replacement, working)
|
|
37
|
+
working = re.sub(
|
|
38
|
+
r"\[([^\]]+)\]\(([^)]+)\)",
|
|
39
|
+
self._link_replacement,
|
|
40
|
+
working,
|
|
41
|
+
)
|
|
42
|
+
working = re.sub(r"\*\*(.+?)\*\*", self._bold_replacement, working)
|
|
43
|
+
working = re.sub(r"__(.+?)__", self._bold_replacement, working)
|
|
44
|
+
working = re.sub(
|
|
45
|
+
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)",
|
|
46
|
+
self._italic_replacement,
|
|
47
|
+
working,
|
|
48
|
+
)
|
|
49
|
+
working = re.sub(r"_(.+?)_", self._italic_replacement, working)
|
|
50
|
+
|
|
51
|
+
escaped = escape_latex(working)
|
|
52
|
+
for key, value in self._placeholders.items():
|
|
53
|
+
escaped = escaped.replace(key, value)
|
|
54
|
+
return escaped
|
|
55
|
+
|
|
56
|
+
def _placeholder(self, value: str) -> str:
|
|
57
|
+
token = f"LATEXPH{next(self._counter)}"
|
|
58
|
+
self._placeholders[token] = value
|
|
59
|
+
return token
|
|
60
|
+
|
|
61
|
+
def _code_replacement(self, match: re.Match[str]) -> str:
|
|
62
|
+
content = escape_latex(match.group(1))
|
|
63
|
+
return self._placeholder(rf"\texttt{{{content}}}")
|
|
64
|
+
|
|
65
|
+
def _link_replacement(self, match: re.Match[str]) -> str:
|
|
66
|
+
label = convert_inline(match.group(1))
|
|
67
|
+
url = escape_url(match.group(2))
|
|
68
|
+
return self._placeholder(rf"\href{{{url}}}{{{label}}}")
|
|
69
|
+
|
|
70
|
+
def _bold_replacement(self, match: re.Match[str]) -> str:
|
|
71
|
+
content = convert_inline(match.group(1))
|
|
72
|
+
return self._placeholder(rf"\textbf{{{content}}}")
|
|
73
|
+
|
|
74
|
+
def _italic_replacement(self, match: re.Match[str]) -> str:
|
|
75
|
+
content = convert_inline(match.group(1))
|
|
76
|
+
return self._placeholder(rf"\textit{{{content}}}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def convert_inline(text: str) -> str:
|
|
80
|
+
r"""Convert simple Markdown inline formatting to LaTeX.
|
|
81
|
+
|
|
82
|
+
This is a pure function that transforms Markdown syntax to LaTeX.
|
|
83
|
+
|
|
84
|
+
Supported Markdown:
|
|
85
|
+
- **bold** or __bold__ → \textbf{bold}
|
|
86
|
+
- *italic* or _italic_ → \textit{italic}
|
|
87
|
+
- `code` → \texttt{code}
|
|
88
|
+
- [text](url) → \href{url}{text}
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
text: Markdown-formatted text.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
LaTeX-formatted text.
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
>>> convert_inline("This is **bold** text")
|
|
98
|
+
'This is \\textbf{bold} text'
|
|
99
|
+
>>> convert_inline("[GitHub](https://github.com)")
|
|
100
|
+
'\\href{https://github.com}{GitHub}'
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
converter = _InlineConverter()
|
|
104
|
+
return converter.convert(text)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def normalize_iterable(value: Any) -> list[str]:
|
|
108
|
+
"""Return a list of strings, regardless of the input type.
|
|
109
|
+
|
|
110
|
+
This is a pure function that coerces various types to a list of strings
|
|
111
|
+
with Markdown-to-LaTeX conversion applied.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
value: Input value (None, str, list, tuple, set, or dict).
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
List of LaTeX-formatted strings.
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
>>> normalize_iterable(["Python", "JavaScript"])
|
|
121
|
+
['Python', 'JavaScript']
|
|
122
|
+
>>> normalize_iterable({"Python": "Expert", "Go": "Intermediate"})
|
|
123
|
+
['Python (Expert)', 'Go (Intermediate)']
|
|
124
|
+
>>> normalize_iterable(None)
|
|
125
|
+
[]
|
|
126
|
+
|
|
127
|
+
"""
|
|
128
|
+
if value is None:
|
|
129
|
+
return []
|
|
130
|
+
if isinstance(value, dict):
|
|
131
|
+
items = []
|
|
132
|
+
for key, val in value.items():
|
|
133
|
+
item = f"{key} ({val})"
|
|
134
|
+
items.append(convert_inline(str(item)))
|
|
135
|
+
return items
|
|
136
|
+
if isinstance(value, (list, tuple, set)):
|
|
137
|
+
return [convert_inline(str(item)) for item in value]
|
|
138
|
+
return [convert_inline(str(value))]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def collect_blocks(description: str | None) -> list[Block]:
|
|
142
|
+
r"""Parse Markdown text into structured blocks for LaTeX rendering.
|
|
143
|
+
|
|
144
|
+
This is a pure function that transforms Markdown text into a list of
|
|
145
|
+
block structures (paragraphs and lists) suitable for LaTeX rendering.
|
|
146
|
+
|
|
147
|
+
Supported Markdown:
|
|
148
|
+
- Paragraphs separated by blank lines
|
|
149
|
+
- Bullet lists (-, *, +)
|
|
150
|
+
- Numbered lists (1., 2., etc.)
|
|
151
|
+
- Multi-line list items (indented continuation)
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
description: Markdown-formatted text (may be None).
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
List of Block structures (ParagraphBlock or ListBlock).
|
|
158
|
+
|
|
159
|
+
Examples:
|
|
160
|
+
>>> blocks = collect_blocks("Paragraph.\\n\\n- Item 1\\n- Item 2")
|
|
161
|
+
>>> len(blocks)
|
|
162
|
+
2
|
|
163
|
+
>>> blocks[0]['kind']
|
|
164
|
+
'paragraph'
|
|
165
|
+
>>> blocks[1]['kind']
|
|
166
|
+
'itemize'
|
|
167
|
+
|
|
168
|
+
"""
|
|
169
|
+
if not description:
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
lines = description.strip("\n").splitlines()
|
|
173
|
+
blocks: list[Block] = []
|
|
174
|
+
current_items: list[str] = []
|
|
175
|
+
ordered = False
|
|
176
|
+
|
|
177
|
+
def flush_items() -> None:
|
|
178
|
+
nonlocal current_items, ordered
|
|
179
|
+
if current_items:
|
|
180
|
+
converted = [convert_inline(item) for item in current_items]
|
|
181
|
+
kind: Literal["itemize", "enumerate"] = (
|
|
182
|
+
"enumerate" if ordered else "itemize"
|
|
183
|
+
)
|
|
184
|
+
blocks.append({"kind": kind, "items": converted})
|
|
185
|
+
current_items = []
|
|
186
|
+
ordered = False
|
|
187
|
+
|
|
188
|
+
for line in lines:
|
|
189
|
+
stripped = line.rstrip()
|
|
190
|
+
if not stripped:
|
|
191
|
+
flush_items()
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
bullet_match = re.match(r"^[-*+]\s+(.*)", stripped)
|
|
195
|
+
ordered_match = re.match(r"^\d+\.\s+(.*)", stripped)
|
|
196
|
+
|
|
197
|
+
if bullet_match:
|
|
198
|
+
if current_items and ordered:
|
|
199
|
+
flush_items()
|
|
200
|
+
ordered = False
|
|
201
|
+
current_items.append(bullet_match.group(1).strip())
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
if ordered_match:
|
|
205
|
+
if current_items and not ordered:
|
|
206
|
+
flush_items()
|
|
207
|
+
ordered = True
|
|
208
|
+
current_items.append(ordered_match.group(1).strip())
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
if stripped.startswith(" ") and current_items:
|
|
212
|
+
current_items[-1] = f"{current_items[-1]} {stripped.strip()}"
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
flush_items()
|
|
216
|
+
paragraph_text = convert_inline(stripped)
|
|
217
|
+
blocks.append({"kind": "paragraph", "text": paragraph_text})
|
|
218
|
+
|
|
219
|
+
flush_items()
|
|
220
|
+
return blocks
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
__all__ = [
|
|
224
|
+
"collect_blocks",
|
|
225
|
+
"convert_inline",
|
|
226
|
+
"normalize_iterable",
|
|
227
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""LaTeX escaping functions (pure, no side effects)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
LATEX_SPECIAL_CHARS = {
|
|
6
|
+
"\\": r"\textbackslash{}",
|
|
7
|
+
"&": r"\&",
|
|
8
|
+
"%": r"\%",
|
|
9
|
+
"$": r"\$",
|
|
10
|
+
"#": r"\#",
|
|
11
|
+
"_": r"\_",
|
|
12
|
+
"{": r"\{",
|
|
13
|
+
"}": r"\}",
|
|
14
|
+
"~": r"\textasciitilde{}",
|
|
15
|
+
"^": r"\textasciicircum{}",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def escape_latex(text: str) -> str:
|
|
20
|
+
r"""Escape LaTeX special characters.
|
|
21
|
+
|
|
22
|
+
This is a pure function that transforms a string by escaping characters
|
|
23
|
+
that have special meaning in LaTeX.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
text: The input string to escape.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The escaped string safe for use in LaTeX documents.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
>>> escape_latex("Price: $50 & up")
|
|
33
|
+
'Price: \\$50 \\& up'
|
|
34
|
+
>>> escape_latex("file_name")
|
|
35
|
+
'file\\_name'
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
return "".join(LATEX_SPECIAL_CHARS.get(char, char) for char in text)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def escape_url(url: str) -> str:
|
|
42
|
+
r"""Escape characters in URLs that break LaTeX hyperlinks.
|
|
43
|
+
|
|
44
|
+
This is a pure function that escapes only the subset of characters
|
|
45
|
+
that cause issues in LaTeX URLs (fewer than general LaTeX escaping).
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
url: The URL to escape.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The escaped URL safe for use in LaTeX \\href commands.
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
>>> escape_url("https://example.com?q=test&foo=bar")
|
|
55
|
+
'https://example.com?q=test\\&foo=bar'
|
|
56
|
+
>>> escape_url("https://example.com/page#section")
|
|
57
|
+
'https://example.com/page\\#section'
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
replacements = {"%": r"\%", "#": r"\#", "&": r"\&", "_": r"\_"}
|
|
61
|
+
return "".join(replacements.get(char, char) for char in url)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"LATEX_SPECIAL_CHARS",
|
|
66
|
+
"escape_latex",
|
|
67
|
+
"escape_url",
|
|
68
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""LaTeX font support functions (pure, no side effects)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import textwrap
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fontawesome_support_block(fontawesome_dir: str | None) -> str:
|
|
9
|
+
r"""Return a LaTeX block that defines the contact icons.
|
|
10
|
+
|
|
11
|
+
This is a pure function that generates LaTeX code for FontAwesome
|
|
12
|
+
icon support. It uses either fontspec (with font files) or fallback
|
|
13
|
+
text-based icons.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
fontawesome_dir: Path to fontawesome fonts directory, or None for fallback.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
LaTeX code block defining icon commands.
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
>>> block = fontawesome_support_block(None)
|
|
23
|
+
>>> r"\\IfFileExists{fontawesome.sty}" in block
|
|
24
|
+
True
|
|
25
|
+
>>> block = fontawesome_support_block("/fonts/")
|
|
26
|
+
>>> r"\\usepackage{fontspec}" in block
|
|
27
|
+
True
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
fallback = textwrap.dedent(
|
|
31
|
+
r"""
|
|
32
|
+
\IfFileExists{fontawesome.sty}{%
|
|
33
|
+
\usepackage{fontawesome}%
|
|
34
|
+
\providecommand{\faLocation}{\faMapMarker}%
|
|
35
|
+
}{
|
|
36
|
+
\newcommand{\faPhone}{\textbf{P}}
|
|
37
|
+
\newcommand{\faEnvelope}{\textbf{@}}
|
|
38
|
+
\newcommand{\faLinkedin}{\textbf{in}}
|
|
39
|
+
\newcommand{\faGlobe}{\textbf{W}}
|
|
40
|
+
\newcommand{\faGithub}{\textbf{GH}}
|
|
41
|
+
\newcommand{\faLocation}{\textbf{A}}
|
|
42
|
+
}
|
|
43
|
+
"""
|
|
44
|
+
).strip()
|
|
45
|
+
|
|
46
|
+
if not fontawesome_dir:
|
|
47
|
+
return fallback
|
|
48
|
+
|
|
49
|
+
fontspec_block = textwrap.dedent(
|
|
50
|
+
rf"""
|
|
51
|
+
\usepackage{{fontspec}}
|
|
52
|
+
\newfontfamily\FAFreeSolid[
|
|
53
|
+
Path={fontawesome_dir},
|
|
54
|
+
Scale=0.72,
|
|
55
|
+
]{{Font Awesome 6 Free-Solid-900.otf}}
|
|
56
|
+
\newfontfamily\FAFreeBrands[
|
|
57
|
+
Path={fontawesome_dir},
|
|
58
|
+
Scale=0.72,
|
|
59
|
+
]{{Font Awesome 6 Brands-Regular-400.otf}}
|
|
60
|
+
\newcommand{{\faPhone}}{{%
|
|
61
|
+
{{\FAFreeSolid\symbol{{"F095}}}}%
|
|
62
|
+
}}
|
|
63
|
+
\newcommand{{\faEnvelope}}{{%
|
|
64
|
+
{{\FAFreeSolid\symbol{{"F0E0}}}}%
|
|
65
|
+
}}
|
|
66
|
+
\newcommand{{\faLinkedin}}{{%
|
|
67
|
+
{{\FAFreeBrands\symbol{{"F08C}}}}%
|
|
68
|
+
}}
|
|
69
|
+
\newcommand{{\faGlobe}}{{%
|
|
70
|
+
{{\FAFreeSolid\symbol{{"F0AC}}}}%
|
|
71
|
+
}}
|
|
72
|
+
\newcommand{{\faGithub}}{{%
|
|
73
|
+
{{\FAFreeBrands\symbol{{"F09B}}}}%
|
|
74
|
+
}}
|
|
75
|
+
\newcommand{{\faLocation}}{{%
|
|
76
|
+
{{\FAFreeSolid\symbol{{"F3C5}}}}%
|
|
77
|
+
}}
|
|
78
|
+
"""
|
|
79
|
+
).strip()
|
|
80
|
+
|
|
81
|
+
lines: list[str] = [r"\usepackage{iftex}", r"\ifPDFTeX"]
|
|
82
|
+
fallback_lines = fallback.splitlines()
|
|
83
|
+
lines.extend(f" {line}" if line else "" for line in fallback_lines)
|
|
84
|
+
lines.append(r"\else")
|
|
85
|
+
fontspec_lines = fontspec_block.splitlines()
|
|
86
|
+
lines.extend(f" {line}" if line else "" for line in fontspec_lines)
|
|
87
|
+
lines.append(r"\fi")
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
__all__ = [
|
|
92
|
+
"fontawesome_support_block",
|
|
93
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""LaTeX formatting functions for dates and links (pure, no side effects)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from simple_resume.core.latex.conversion import convert_inline
|
|
6
|
+
from simple_resume.core.latex.escaping import escape_url
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def format_date(start: str | None, end: str | None) -> str | None:
|
|
10
|
+
"""Format start and end dates for LaTeX rendering.
|
|
11
|
+
|
|
12
|
+
This is a pure function that formats date ranges according to resume
|
|
13
|
+
conventions.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
start: Start date string (may be None or empty).
|
|
17
|
+
end: End date string (may be None or empty).
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Formatted date range string, or None if both inputs are empty.
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
>>> format_date("2020", "2023")
|
|
24
|
+
'2020 -- 2023'
|
|
25
|
+
>>> format_date("2023", "2023")
|
|
26
|
+
'2023'
|
|
27
|
+
>>> format_date("2020", "Present")
|
|
28
|
+
'2020 -- Present'
|
|
29
|
+
>>> format_date(None, None)
|
|
30
|
+
None
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
start_clean = start.strip() if isinstance(start, str) else ""
|
|
34
|
+
end_clean = end.strip() if isinstance(end, str) else ""
|
|
35
|
+
|
|
36
|
+
if start_clean and end_clean:
|
|
37
|
+
if start_clean == end_clean:
|
|
38
|
+
return convert_inline(start_clean)
|
|
39
|
+
return convert_inline(f"{start_clean} -- {end_clean}")
|
|
40
|
+
if end_clean:
|
|
41
|
+
return convert_inline(end_clean)
|
|
42
|
+
if start_clean:
|
|
43
|
+
return convert_inline(start_clean)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def linkify(text: str | None, link: str | None) -> str | None:
|
|
48
|
+
r"""Convert text to a hyperlink if a URL is provided.
|
|
49
|
+
|
|
50
|
+
This is a pure function that creates LaTeX \\href commands when
|
|
51
|
+
a link is provided, or returns the text as-is if no link.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
text: The text to display (may be None).
|
|
55
|
+
link: The URL to link to (may be None or empty).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
LaTeX hyperlink command if link is provided, plain text otherwise,
|
|
59
|
+
or None if text is empty.
|
|
60
|
+
|
|
61
|
+
Examples:
|
|
62
|
+
>>> linkify("Company", "https://example.com")
|
|
63
|
+
'\\href{https://example.com}{Company}'
|
|
64
|
+
>>> linkify("Company", None)
|
|
65
|
+
'Company'
|
|
66
|
+
>>> linkify(None, "https://example.com")
|
|
67
|
+
None
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
if not text:
|
|
71
|
+
return None
|
|
72
|
+
rendered = convert_inline(text)
|
|
73
|
+
if link:
|
|
74
|
+
return rf"\href{{{escape_url(link)}}}{{{rendered}}}"
|
|
75
|
+
return rendered
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
"format_date",
|
|
80
|
+
"linkify",
|
|
81
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""LaTeX section preparation functions (pure, no side effects)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from simple_resume.core.latex.conversion import collect_blocks, convert_inline
|
|
9
|
+
from simple_resume.core.latex.escaping import escape_latex, escape_url
|
|
10
|
+
from simple_resume.core.latex.formatting import format_date, linkify
|
|
11
|
+
from simple_resume.core.latex.types import LatexEntry, LatexSection
|
|
12
|
+
from simple_resume.core.skills import format_skill_groups
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_contact_lines(data: dict[str, Any]) -> list[str]:
|
|
16
|
+
"""Build contact information lines from resume data.
|
|
17
|
+
|
|
18
|
+
This is a pure function that transforms resume data into LaTeX-formatted
|
|
19
|
+
contact lines with FontAwesome icons.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
data: Resume data dictionary containing contact fields.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
List of LaTeX-formatted contact lines.
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
>>> data = {"email": "user@example.com", "phone": "555-1234"}
|
|
29
|
+
>>> lines = build_contact_lines(data)
|
|
30
|
+
>>> len(lines)
|
|
31
|
+
2
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
lines: list[str] = []
|
|
35
|
+
|
|
36
|
+
def _icon_prefix(icon: str) -> str:
|
|
37
|
+
return rf"\{icon}\enspace "
|
|
38
|
+
|
|
39
|
+
# Address handling
|
|
40
|
+
address = data.get("address")
|
|
41
|
+
if isinstance(address, Iterable) and not isinstance(address, (str, bytes)):
|
|
42
|
+
joined = ", ".join(str(part) for part in address if part)
|
|
43
|
+
elif address:
|
|
44
|
+
joined = str(address)
|
|
45
|
+
else:
|
|
46
|
+
joined = ""
|
|
47
|
+
|
|
48
|
+
if joined:
|
|
49
|
+
lines.append(f"{_icon_prefix('faLocation')}{convert_inline(joined)}")
|
|
50
|
+
|
|
51
|
+
# Phone
|
|
52
|
+
phone = data.get("phone")
|
|
53
|
+
if phone:
|
|
54
|
+
lines.append(f"{_icon_prefix('faPhone')}{escape_latex(str(phone))}")
|
|
55
|
+
|
|
56
|
+
# Email
|
|
57
|
+
email = data.get("email")
|
|
58
|
+
if email:
|
|
59
|
+
lines.append(
|
|
60
|
+
rf"{_icon_prefix('faEnvelope')}\href{{mailto:{escape_url(email)}}}{{\nolinkurl{{{escape_latex(email)}}}}}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# GitHub
|
|
64
|
+
github_added = False
|
|
65
|
+
github = data.get("github")
|
|
66
|
+
if github:
|
|
67
|
+
gh_value = str(github)
|
|
68
|
+
gh_url = (
|
|
69
|
+
gh_value
|
|
70
|
+
if gh_value.startswith("http")
|
|
71
|
+
else f"https://github.com/{gh_value.lstrip('/')}"
|
|
72
|
+
)
|
|
73
|
+
lines.append(
|
|
74
|
+
rf"{_icon_prefix('faGithub')}\href{{{escape_url(gh_url)}}}{{\nolinkurl{{{escape_latex(gh_value)}}}}}"
|
|
75
|
+
)
|
|
76
|
+
github_added = True
|
|
77
|
+
|
|
78
|
+
# Web
|
|
79
|
+
web = data.get("web")
|
|
80
|
+
if web:
|
|
81
|
+
web_value = str(web)
|
|
82
|
+
icon = "faGithub" if "github.com" in web_value.lower() else "faGlobe"
|
|
83
|
+
if icon == "faGithub" and github_added:
|
|
84
|
+
pass # Skip duplicate GitHub entry
|
|
85
|
+
else:
|
|
86
|
+
lines.append(
|
|
87
|
+
rf"{_icon_prefix(icon)}\href{{{escape_url(web_value)}}}{{\nolinkurl{{{escape_latex(web_value)}}}}}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# LinkedIn
|
|
91
|
+
linkedin = data.get("linkedin")
|
|
92
|
+
if linkedin:
|
|
93
|
+
url = linkedin
|
|
94
|
+
if not url.startswith("http"):
|
|
95
|
+
url = f"https://www.linkedin.com/{linkedin.lstrip('/')}"
|
|
96
|
+
lines.append(
|
|
97
|
+
rf"{_icon_prefix('faLinkedin')}\href{{{escape_url(url)}}}{{\nolinkurl{{{escape_latex(linkedin)}}}}}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return lines
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def prepare_sections(data: dict[str, Any]) -> list[LatexSection]:
|
|
104
|
+
"""Prepare resume body sections from data.
|
|
105
|
+
|
|
106
|
+
This is a pure function that transforms resume data into structured
|
|
107
|
+
LatexSection objects ready for template rendering.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
data: Resume data dictionary with 'body' key containing sections.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
List of LatexSection objects.
|
|
114
|
+
|
|
115
|
+
Examples:
|
|
116
|
+
>>> data = {
|
|
117
|
+
... "body": {
|
|
118
|
+
... "Experience": [
|
|
119
|
+
... {"title": "Software Engineer", "company": "Tech Corp"}
|
|
120
|
+
... ]
|
|
121
|
+
... }
|
|
122
|
+
... }
|
|
123
|
+
>>> sections = prepare_sections(data)
|
|
124
|
+
>>> len(sections)
|
|
125
|
+
1
|
|
126
|
+
|
|
127
|
+
"""
|
|
128
|
+
sections: list[LatexSection] = []
|
|
129
|
+
body = data.get("body")
|
|
130
|
+
if not isinstance(body, dict):
|
|
131
|
+
return sections
|
|
132
|
+
|
|
133
|
+
for section_name, entries in body.items():
|
|
134
|
+
if not isinstance(entries, list):
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
rendered_title = convert_inline(str(section_name))
|
|
138
|
+
rendered_entries: list[LatexEntry] = []
|
|
139
|
+
|
|
140
|
+
for entry in entries:
|
|
141
|
+
if not isinstance(entry, dict):
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
title = linkify(entry.get("title"), entry.get("title_link"))
|
|
145
|
+
subtitle = linkify(entry.get("company"), entry.get("company_link"))
|
|
146
|
+
date_range = format_date(entry.get("start"), entry.get("end"))
|
|
147
|
+
blocks = collect_blocks(entry.get("description"))
|
|
148
|
+
|
|
149
|
+
rendered_entries.append(
|
|
150
|
+
LatexEntry(
|
|
151
|
+
title=title or "",
|
|
152
|
+
subtitle=subtitle,
|
|
153
|
+
date_range=date_range,
|
|
154
|
+
blocks=blocks,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if rendered_entries:
|
|
159
|
+
sections.append(
|
|
160
|
+
LatexSection(title=rendered_title, entries=rendered_entries)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return sections
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def prepare_skill_sections(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
167
|
+
"""Prepare skill sections from resume data.
|
|
168
|
+
|
|
169
|
+
This is a pure function that transforms skill data into a list of
|
|
170
|
+
skill group dictionaries ready for LaTeX rendering.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
data: Resume data containing skill fields (expertise, programming, etc.).
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
List of skill section dictionaries with title and items.
|
|
177
|
+
|
|
178
|
+
Examples:
|
|
179
|
+
>>> data = {"expertise": ["Python", "JavaScript"]}
|
|
180
|
+
>>> sections = prepare_skill_sections(data)
|
|
181
|
+
>>> sections[0]["title"]
|
|
182
|
+
'Expertise'
|
|
183
|
+
|
|
184
|
+
"""
|
|
185
|
+
titles = data.get("titles", {})
|
|
186
|
+
skill_sections: list[dict[str, Any]] = []
|
|
187
|
+
|
|
188
|
+
def append_groups(raw_value: Any, default_title: str) -> None:
|
|
189
|
+
for group in format_skill_groups(raw_value):
|
|
190
|
+
raw_items = group["items"]
|
|
191
|
+
if not isinstance(raw_items, (list, tuple, set)):
|
|
192
|
+
continue
|
|
193
|
+
items = [convert_inline(str(item)) for item in raw_items if item]
|
|
194
|
+
if not items:
|
|
195
|
+
continue
|
|
196
|
+
title = group["title"] or default_title
|
|
197
|
+
skill_sections.append(
|
|
198
|
+
{
|
|
199
|
+
"title": convert_inline(str(title)),
|
|
200
|
+
"items": items,
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
append_groups(data.get("expertise"), titles.get("expertise", "Expertise"))
|
|
205
|
+
append_groups(data.get("programming"), titles.get("programming", "Programming"))
|
|
206
|
+
append_groups(data.get("keyskills"), titles.get("keyskills", "Key Skills"))
|
|
207
|
+
append_groups(
|
|
208
|
+
data.get("certification"), titles.get("certification", "Certifications")
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return skill_sections
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = [
|
|
215
|
+
"build_contact_lines",
|
|
216
|
+
"prepare_sections",
|
|
217
|
+
"prepare_skill_sections",
|
|
218
|
+
]
|