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.
Files changed (116) hide show
  1. simple_resume/__init__.py +132 -0
  2. simple_resume/core/__init__.py +47 -0
  3. simple_resume/core/colors.py +215 -0
  4. simple_resume/core/config.py +672 -0
  5. simple_resume/core/constants/__init__.py +207 -0
  6. simple_resume/core/constants/colors.py +98 -0
  7. simple_resume/core/constants/files.py +28 -0
  8. simple_resume/core/constants/layout.py +58 -0
  9. simple_resume/core/dependencies.py +258 -0
  10. simple_resume/core/effects.py +154 -0
  11. simple_resume/core/exceptions.py +261 -0
  12. simple_resume/core/file_operations.py +68 -0
  13. simple_resume/core/generate/__init__.py +21 -0
  14. simple_resume/core/generate/exceptions.py +69 -0
  15. simple_resume/core/generate/html.py +233 -0
  16. simple_resume/core/generate/pdf.py +659 -0
  17. simple_resume/core/generate/plan.py +131 -0
  18. simple_resume/core/hydration.py +55 -0
  19. simple_resume/core/importers/__init__.py +3 -0
  20. simple_resume/core/importers/json_resume.py +284 -0
  21. simple_resume/core/latex/__init__.py +60 -0
  22. simple_resume/core/latex/context.py +56 -0
  23. simple_resume/core/latex/conversion.py +227 -0
  24. simple_resume/core/latex/escaping.py +68 -0
  25. simple_resume/core/latex/fonts.py +93 -0
  26. simple_resume/core/latex/formatting.py +81 -0
  27. simple_resume/core/latex/sections.py +218 -0
  28. simple_resume/core/latex/types.py +84 -0
  29. simple_resume/core/markdown.py +127 -0
  30. simple_resume/core/models.py +102 -0
  31. simple_resume/core/palettes/__init__.py +38 -0
  32. simple_resume/core/palettes/common.py +73 -0
  33. simple_resume/core/palettes/data/default_palettes.json +58 -0
  34. simple_resume/core/palettes/exceptions.py +33 -0
  35. simple_resume/core/palettes/fetch_types.py +52 -0
  36. simple_resume/core/palettes/generators.py +137 -0
  37. simple_resume/core/palettes/registry.py +76 -0
  38. simple_resume/core/palettes/resolution.py +123 -0
  39. simple_resume/core/palettes/sources.py +162 -0
  40. simple_resume/core/paths.py +21 -0
  41. simple_resume/core/protocols.py +134 -0
  42. simple_resume/core/py.typed +0 -0
  43. simple_resume/core/render/__init__.py +37 -0
  44. simple_resume/core/render/manage.py +199 -0
  45. simple_resume/core/render/plan.py +405 -0
  46. simple_resume/core/result.py +226 -0
  47. simple_resume/core/resume.py +609 -0
  48. simple_resume/core/skills.py +60 -0
  49. simple_resume/core/validation.py +321 -0
  50. simple_resume/py.typed +0 -0
  51. simple_resume/shell/__init__.py +3 -0
  52. simple_resume/shell/assets/static/css/README.md +213 -0
  53. simple_resume/shell/assets/static/css/common.css +641 -0
  54. simple_resume/shell/assets/static/css/fonts.css +42 -0
  55. simple_resume/shell/assets/static/css/preview.css +82 -0
  56. simple_resume/shell/assets/static/css/print.css +99 -0
  57. simple_resume/shell/assets/static/fonts/AvenirLTStd-Book.otf +0 -0
  58. simple_resume/shell/assets/static/fonts/AvenirLTStd-Light.otf +0 -0
  59. simple_resume/shell/assets/static/fonts/AvenirLTStd-Medium.otf +0 -0
  60. simple_resume/shell/assets/static/fonts/AvenirLTStd-Oblique.otf +0 -0
  61. simple_resume/shell/assets/static/fonts/AvenirLTStd-Roman.otf +0 -0
  62. simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Brands-Regular-400.otf +0 -0
  63. simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Free-Solid-900.otf +0 -0
  64. simple_resume/shell/assets/static/images/default_profile_1.jpg +0 -0
  65. simple_resume/shell/assets/static/images/default_profile_2.png +0 -0
  66. simple_resume/shell/assets/static/schema.json +236 -0
  67. simple_resume/shell/assets/static/themes/README.md +208 -0
  68. simple_resume/shell/assets/static/themes/bold.yaml +64 -0
  69. simple_resume/shell/assets/static/themes/classic.yaml +64 -0
  70. simple_resume/shell/assets/static/themes/executive.yaml +64 -0
  71. simple_resume/shell/assets/static/themes/minimal.yaml +64 -0
  72. simple_resume/shell/assets/static/themes/modern.yaml +64 -0
  73. simple_resume/shell/assets/templates/html/cover.html +129 -0
  74. simple_resume/shell/assets/templates/html/demo.html +13 -0
  75. simple_resume/shell/assets/templates/html/resume_base.html +453 -0
  76. simple_resume/shell/assets/templates/html/resume_no_bars.html +316 -0
  77. simple_resume/shell/assets/templates/html/resume_with_bars.html +362 -0
  78. simple_resume/shell/cli/__init__.py +35 -0
  79. simple_resume/shell/cli/main.py +975 -0
  80. simple_resume/shell/cli/palette.py +75 -0
  81. simple_resume/shell/cli/random_palette_demo.py +407 -0
  82. simple_resume/shell/config.py +96 -0
  83. simple_resume/shell/effect_executor.py +211 -0
  84. simple_resume/shell/file_opener.py +308 -0
  85. simple_resume/shell/generate/__init__.py +37 -0
  86. simple_resume/shell/generate/core.py +650 -0
  87. simple_resume/shell/generate/lazy.py +284 -0
  88. simple_resume/shell/io_utils.py +199 -0
  89. simple_resume/shell/palettes/__init__.py +1 -0
  90. simple_resume/shell/palettes/fetch.py +63 -0
  91. simple_resume/shell/palettes/loader.py +321 -0
  92. simple_resume/shell/palettes/remote.py +179 -0
  93. simple_resume/shell/pdf_executor.py +52 -0
  94. simple_resume/shell/py.typed +0 -0
  95. simple_resume/shell/render/__init__.py +1 -0
  96. simple_resume/shell/render/latex.py +308 -0
  97. simple_resume/shell/render/operations.py +240 -0
  98. simple_resume/shell/resume_extensions.py +737 -0
  99. simple_resume/shell/runtime/__init__.py +7 -0
  100. simple_resume/shell/runtime/content.py +190 -0
  101. simple_resume/shell/runtime/generate.py +497 -0
  102. simple_resume/shell/runtime/lazy.py +138 -0
  103. simple_resume/shell/runtime/lazy_import.py +173 -0
  104. simple_resume/shell/service_locator.py +80 -0
  105. simple_resume/shell/services.py +256 -0
  106. simple_resume/shell/session/__init__.py +6 -0
  107. simple_resume/shell/session/config.py +35 -0
  108. simple_resume/shell/session/manage.py +386 -0
  109. simple_resume/shell/strategies.py +181 -0
  110. simple_resume/shell/themes/__init__.py +35 -0
  111. simple_resume/shell/themes/loader.py +230 -0
  112. simple_resume-0.1.9.dist-info/METADATA +201 -0
  113. simple_resume-0.1.9.dist-info/RECORD +116 -0
  114. simple_resume-0.1.9.dist-info/WHEEL +4 -0
  115. simple_resume-0.1.9.dist-info/entry_points.txt +5 -0
  116. 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
+ ]