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,737 @@
|
|
|
1
|
+
"""Shell layer functions for Resume I/O operations.
|
|
2
|
+
|
|
3
|
+
This module provides the I/O operations for Resume that live in the shell layer,
|
|
4
|
+
keeping the core Resume class pure and functional.
|
|
5
|
+
|
|
6
|
+
Functions:
|
|
7
|
+
to_pdf: Generate PDF from a Resume
|
|
8
|
+
to_html: Generate HTML from a Resume
|
|
9
|
+
to_markdown: Generate intermediate Markdown from a Resume
|
|
10
|
+
to_tex: Generate intermediate LaTeX from a Resume
|
|
11
|
+
generate: Generate output in specified format from a Resume
|
|
12
|
+
render_markdown_file: Render an existing .md file to HTML
|
|
13
|
+
render_tex_file: Render an existing .tex file to PDF
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import copy
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
21
|
+
|
|
22
|
+
from simple_resume.core.constants import MARKDOWN_EXTENSION, TEX_EXTENSION, OutputFormat
|
|
23
|
+
from simple_resume.core.exceptions import ConfigurationError, GenerationError
|
|
24
|
+
from simple_resume.core.protocols import PdfGenerationStrategy
|
|
25
|
+
from simple_resume.core.result import GenerationResult
|
|
26
|
+
from simple_resume.shell.file_opener import open_file as shell_open_file
|
|
27
|
+
from simple_resume.shell.render.latex import LatexCompilationError
|
|
28
|
+
from simple_resume.shell.render.operations import generate_html_with_jinja
|
|
29
|
+
from simple_resume.shell.services import DefaultPdfGenerationStrategy
|
|
30
|
+
from simple_resume.shell.strategies import PdfGenerationRequest
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from simple_resume.core.resume import Resume
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_pdf_strategy(mode: str) -> PdfGenerationStrategy:
|
|
37
|
+
"""Get the appropriate PDF generation strategy from service locator."""
|
|
38
|
+
return DefaultPdfGenerationStrategy(mode)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def to_pdf(
|
|
42
|
+
resume: Resume,
|
|
43
|
+
output_path: Path | str | None = None,
|
|
44
|
+
*,
|
|
45
|
+
open_after: bool = False,
|
|
46
|
+
strategy: PdfGenerationStrategy | None = None,
|
|
47
|
+
) -> GenerationResult:
|
|
48
|
+
"""Generate PDF from a Resume.
|
|
49
|
+
|
|
50
|
+
This is the shell-layer implementation that handles PDF generation
|
|
51
|
+
with proper strategy injection and shell service dependencies.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
resume: The Resume instance to generate PDF from.
|
|
55
|
+
output_path: Optional output path (defaults to output directory).
|
|
56
|
+
open_after: Whether to open the PDF after generation.
|
|
57
|
+
strategy: Optional custom PDF generation strategy (for testing).
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
GenerationResult with metadata and output path.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ConfigurationError: If paths are not available.
|
|
64
|
+
GenerationError: If PDF generation fails.
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
# Prepare render plan
|
|
69
|
+
render_plan = resume.prepare_render_plan(preview=False)
|
|
70
|
+
|
|
71
|
+
# Determine output path
|
|
72
|
+
if output_path is None:
|
|
73
|
+
if resume.paths is None:
|
|
74
|
+
raise ConfigurationError(
|
|
75
|
+
"No paths available - provide output_path or create with paths",
|
|
76
|
+
filename=resume.filename,
|
|
77
|
+
)
|
|
78
|
+
resolved_path = resume.paths.output / f"{resume.name}.pdf"
|
|
79
|
+
else:
|
|
80
|
+
resolved_path = Path(output_path)
|
|
81
|
+
|
|
82
|
+
# Create PDF generation request
|
|
83
|
+
request = PdfGenerationRequest(
|
|
84
|
+
render_plan=render_plan,
|
|
85
|
+
output_path=resolved_path,
|
|
86
|
+
open_after=open_after,
|
|
87
|
+
filename=resume.filename,
|
|
88
|
+
resume_name=resume.name,
|
|
89
|
+
raw_data=copy.deepcopy(resume.raw_data),
|
|
90
|
+
processed_data=copy.deepcopy(resume.data),
|
|
91
|
+
paths=resume.paths,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Select appropriate strategy (injected or default)
|
|
95
|
+
if strategy is None:
|
|
96
|
+
strategy = _get_pdf_strategy(render_plan.mode.value)
|
|
97
|
+
|
|
98
|
+
# Generate PDF using strategy
|
|
99
|
+
result, page_count = strategy.generate(
|
|
100
|
+
render_plan=request,
|
|
101
|
+
output_path=request.output_path,
|
|
102
|
+
resume_name=request.resume_name,
|
|
103
|
+
filename=request.filename,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return cast(GenerationResult, result)
|
|
107
|
+
|
|
108
|
+
except (ConfigurationError, GenerationError):
|
|
109
|
+
raise
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
raise GenerationError(
|
|
112
|
+
f"Failed to generate PDF: {exc}",
|
|
113
|
+
format_type="pdf",
|
|
114
|
+
output_path=output_path,
|
|
115
|
+
filename=resume.filename,
|
|
116
|
+
) from exc
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def to_html(
|
|
120
|
+
resume: Resume,
|
|
121
|
+
output_path: Path | str | None = None,
|
|
122
|
+
*,
|
|
123
|
+
open_after: bool = False,
|
|
124
|
+
browser: str | None = None,
|
|
125
|
+
) -> GenerationResult:
|
|
126
|
+
"""Generate HTML from a Resume.
|
|
127
|
+
|
|
128
|
+
This is the shell-layer implementation that handles HTML generation
|
|
129
|
+
with proper service injection and dependencies.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
resume: The Resume instance to generate HTML from.
|
|
133
|
+
output_path: Optional output path (defaults to output directory).
|
|
134
|
+
open_after: Whether to open HTML after generation.
|
|
135
|
+
browser: Optional browser command for opening (unused, for API compat).
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
GenerationResult with metadata and output path.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ConfigurationError: If paths are not available.
|
|
142
|
+
GenerationError: If HTML generation fails.
|
|
143
|
+
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
# Validate data first
|
|
147
|
+
resume.validate_or_raise()
|
|
148
|
+
|
|
149
|
+
# Prepare render plan
|
|
150
|
+
render_plan = resume.prepare_render_plan(preview=True)
|
|
151
|
+
|
|
152
|
+
# Determine output path
|
|
153
|
+
if output_path is None:
|
|
154
|
+
if resume.paths is None:
|
|
155
|
+
raise ConfigurationError(
|
|
156
|
+
"No paths available - provide output_path or create with paths",
|
|
157
|
+
filename=resume.filename,
|
|
158
|
+
)
|
|
159
|
+
resolved_path = resume.paths.output / f"{resume.name}.html"
|
|
160
|
+
else:
|
|
161
|
+
resolved_path = Path(output_path)
|
|
162
|
+
|
|
163
|
+
# Generate HTML using shell renderer
|
|
164
|
+
result = generate_html_with_jinja(
|
|
165
|
+
render_plan, resolved_path, filename=resume.filename
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Open file if requested
|
|
169
|
+
if open_after and result.output_path.exists():
|
|
170
|
+
shell_open_file(result.output_path, format_type="html")
|
|
171
|
+
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
except (ConfigurationError, GenerationError):
|
|
175
|
+
raise
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
raise GenerationError(
|
|
178
|
+
f"Failed to generate HTML: {exc}",
|
|
179
|
+
format_type="html",
|
|
180
|
+
output_path=output_path,
|
|
181
|
+
filename=resume.filename,
|
|
182
|
+
) from exc
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def to_markdown(
|
|
186
|
+
resume: Resume,
|
|
187
|
+
output_path: Path | str | None = None,
|
|
188
|
+
*,
|
|
189
|
+
open_after: bool = False,
|
|
190
|
+
) -> GenerationResult:
|
|
191
|
+
"""Generate intermediate Markdown from a Resume.
|
|
192
|
+
|
|
193
|
+
This creates an editable .md file that can be modified before
|
|
194
|
+
rendering to HTML. Use render_markdown_file() to convert to HTML.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
resume: The Resume instance to generate Markdown from.
|
|
198
|
+
output_path: Optional output path (defaults to output directory).
|
|
199
|
+
open_after: Whether to open the file after generation.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
GenerationResult with metadata and output path.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
ConfigurationError: If paths are not available.
|
|
206
|
+
GenerationError: If generation fails.
|
|
207
|
+
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
# Validate data first
|
|
211
|
+
resume.validate_or_raise()
|
|
212
|
+
|
|
213
|
+
# Prepare render plan for HTML mode (markdown is the HTML intermediate)
|
|
214
|
+
render_plan = resume.prepare_render_plan(preview=True)
|
|
215
|
+
|
|
216
|
+
# Determine output path
|
|
217
|
+
if output_path is None:
|
|
218
|
+
if resume.paths is None:
|
|
219
|
+
raise ConfigurationError(
|
|
220
|
+
"No paths available - provide output_path or create with paths",
|
|
221
|
+
filename=resume.filename,
|
|
222
|
+
)
|
|
223
|
+
resolved_path = resume.paths.output / f"{resume.name}{MARKDOWN_EXTENSION}"
|
|
224
|
+
else:
|
|
225
|
+
resolved_path = Path(output_path)
|
|
226
|
+
|
|
227
|
+
# Get context from render plan and generate markdown
|
|
228
|
+
context = render_plan.context or {}
|
|
229
|
+
|
|
230
|
+
# Generate structured markdown from context
|
|
231
|
+
md_content = _generate_markdown_from_context(context, resume.name)
|
|
232
|
+
|
|
233
|
+
# Ensure output directory exists
|
|
234
|
+
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
|
|
236
|
+
# Write markdown file
|
|
237
|
+
resolved_path.write_text(md_content, encoding="utf-8")
|
|
238
|
+
|
|
239
|
+
# Create metadata
|
|
240
|
+
from simple_resume.core.result import GenerationMetadata # noqa: PLC0415
|
|
241
|
+
|
|
242
|
+
metadata = GenerationMetadata(
|
|
243
|
+
format_type="markdown",
|
|
244
|
+
template_name=render_plan.template_name or "markdown",
|
|
245
|
+
generation_time=0.0,
|
|
246
|
+
file_size=len(md_content.encode("utf-8")),
|
|
247
|
+
resume_name=resume.name,
|
|
248
|
+
palette_info=render_plan.palette_metadata,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
result = GenerationResult(
|
|
252
|
+
output_path=resolved_path,
|
|
253
|
+
format_type="markdown",
|
|
254
|
+
metadata=metadata,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Open file if requested
|
|
258
|
+
if open_after and result.output_path.exists():
|
|
259
|
+
shell_open_file(result.output_path, format_type="markdown")
|
|
260
|
+
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
except (ConfigurationError, GenerationError):
|
|
264
|
+
raise
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
raise GenerationError(
|
|
267
|
+
f"Failed to generate Markdown: {exc}",
|
|
268
|
+
format_type="markdown",
|
|
269
|
+
output_path=output_path,
|
|
270
|
+
filename=resume.filename,
|
|
271
|
+
) from exc
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _generate_markdown_from_context( # noqa: PLR0912, PLR0915
|
|
275
|
+
context: dict[str, object], resume_name: str
|
|
276
|
+
) -> str:
|
|
277
|
+
"""Generate structured markdown content from render context.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
context: Render context dictionary containing resume data.
|
|
281
|
+
resume_name: Name of the resume for the title.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Formatted markdown string.
|
|
285
|
+
|
|
286
|
+
"""
|
|
287
|
+
lines: list[str] = []
|
|
288
|
+
|
|
289
|
+
# Header with name
|
|
290
|
+
full_name = context.get("full_name", resume_name)
|
|
291
|
+
lines.append(f"# {full_name}")
|
|
292
|
+
lines.append("")
|
|
293
|
+
|
|
294
|
+
# Contact info
|
|
295
|
+
if "email" in context or "phone" in context or "location" in context:
|
|
296
|
+
contact_parts: list[str] = []
|
|
297
|
+
if context.get("email"):
|
|
298
|
+
contact_parts.append(str(context["email"]))
|
|
299
|
+
if context.get("phone"):
|
|
300
|
+
contact_parts.append(str(context["phone"]))
|
|
301
|
+
if context.get("location"):
|
|
302
|
+
contact_parts.append(str(context["location"]))
|
|
303
|
+
if contact_parts:
|
|
304
|
+
lines.append(" | ".join(contact_parts))
|
|
305
|
+
lines.append("")
|
|
306
|
+
|
|
307
|
+
# Links
|
|
308
|
+
links = context.get("links") or []
|
|
309
|
+
if links and isinstance(links, list):
|
|
310
|
+
link_parts: list[str] = []
|
|
311
|
+
for link in links:
|
|
312
|
+
if isinstance(link, dict):
|
|
313
|
+
link_dict = cast(dict[str, Any], link)
|
|
314
|
+
url = link_dict.get("url", "")
|
|
315
|
+
label = link_dict.get("label", url)
|
|
316
|
+
link_parts.append(f"[{label}]({url})")
|
|
317
|
+
if link_parts:
|
|
318
|
+
lines.append(" | ".join(link_parts))
|
|
319
|
+
lines.append("")
|
|
320
|
+
|
|
321
|
+
# Summary/Objective
|
|
322
|
+
if context.get("summary"):
|
|
323
|
+
lines.append("## Summary")
|
|
324
|
+
lines.append("")
|
|
325
|
+
lines.append(str(context["summary"]))
|
|
326
|
+
lines.append("")
|
|
327
|
+
|
|
328
|
+
# Experience
|
|
329
|
+
experience = context.get("experience") or []
|
|
330
|
+
if experience and isinstance(experience, list):
|
|
331
|
+
lines.append("## Experience")
|
|
332
|
+
lines.append("")
|
|
333
|
+
for job in experience:
|
|
334
|
+
if isinstance(job, dict):
|
|
335
|
+
job_dict = cast(dict[str, Any], job)
|
|
336
|
+
title = job_dict.get("title", "")
|
|
337
|
+
company = job_dict.get("company", "")
|
|
338
|
+
dates = job_dict.get("dates", "")
|
|
339
|
+
lines.append(f"### {title} at {company}")
|
|
340
|
+
if dates:
|
|
341
|
+
lines.append(f"*{dates}*")
|
|
342
|
+
lines.append("")
|
|
343
|
+
highlights = job_dict.get("highlights", [])
|
|
344
|
+
for highlight in highlights:
|
|
345
|
+
lines.append(f"- {highlight}")
|
|
346
|
+
lines.append("")
|
|
347
|
+
|
|
348
|
+
# Education
|
|
349
|
+
education = context.get("education") or []
|
|
350
|
+
if education and isinstance(education, list):
|
|
351
|
+
lines.append("## Education")
|
|
352
|
+
lines.append("")
|
|
353
|
+
for edu in education:
|
|
354
|
+
if isinstance(edu, dict):
|
|
355
|
+
edu_dict = cast(dict[str, Any], edu)
|
|
356
|
+
degree = edu_dict.get("degree", "")
|
|
357
|
+
school = edu_dict.get("school", "")
|
|
358
|
+
dates = edu_dict.get("dates", "")
|
|
359
|
+
lines.append(f"### {degree}")
|
|
360
|
+
lines.append(f"*{school}*")
|
|
361
|
+
if dates:
|
|
362
|
+
lines.append(f"*{dates}*")
|
|
363
|
+
lines.append("")
|
|
364
|
+
|
|
365
|
+
# Skills
|
|
366
|
+
skills = context.get("skills") or []
|
|
367
|
+
if skills and isinstance(skills, list):
|
|
368
|
+
lines.append("## Skills")
|
|
369
|
+
lines.append("")
|
|
370
|
+
for skill_group in skills:
|
|
371
|
+
if isinstance(skill_group, dict):
|
|
372
|
+
skill_dict = cast(dict[str, Any], skill_group)
|
|
373
|
+
category = skill_dict.get("category", "")
|
|
374
|
+
items = skill_dict.get("items", [])
|
|
375
|
+
if category:
|
|
376
|
+
lines.append(f"**{category}:** {', '.join(items)}")
|
|
377
|
+
else:
|
|
378
|
+
lines.append(", ".join(items))
|
|
379
|
+
lines.append("")
|
|
380
|
+
|
|
381
|
+
return "\n".join(lines)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def to_tex(
|
|
385
|
+
resume: Resume,
|
|
386
|
+
output_path: Path | str | None = None,
|
|
387
|
+
*,
|
|
388
|
+
open_after: bool = False,
|
|
389
|
+
) -> GenerationResult:
|
|
390
|
+
"""Generate intermediate LaTeX (.tex) from a Resume.
|
|
391
|
+
|
|
392
|
+
This creates an editable .tex file that can be modified before
|
|
393
|
+
rendering to PDF. Use render_tex_file() to convert to PDF.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
resume: The Resume instance to generate LaTeX from.
|
|
397
|
+
output_path: Optional output path (defaults to output directory).
|
|
398
|
+
open_after: Whether to open the file after generation.
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
GenerationResult with metadata and output path.
|
|
402
|
+
|
|
403
|
+
Raises:
|
|
404
|
+
ConfigurationError: If paths are not available.
|
|
405
|
+
ValidationError: If resume data fails validation.
|
|
406
|
+
GenerationError: If LaTeX generation fails.
|
|
407
|
+
|
|
408
|
+
"""
|
|
409
|
+
try:
|
|
410
|
+
# Validate data first (consistent with to_markdown)
|
|
411
|
+
resume.validate_or_raise()
|
|
412
|
+
|
|
413
|
+
# Prepare render plan for LaTeX mode
|
|
414
|
+
render_plan = resume.prepare_render_plan(preview=False)
|
|
415
|
+
|
|
416
|
+
# Determine output path
|
|
417
|
+
if output_path is None:
|
|
418
|
+
if resume.paths is None:
|
|
419
|
+
raise ConfigurationError(
|
|
420
|
+
"No paths available - provide output_path or create with paths",
|
|
421
|
+
filename=resume.filename,
|
|
422
|
+
)
|
|
423
|
+
resolved_path = resume.paths.output / f"{resume.name}{TEX_EXTENSION}"
|
|
424
|
+
else:
|
|
425
|
+
resolved_path = Path(output_path)
|
|
426
|
+
|
|
427
|
+
# Use the shell-layer render functions which have all dependencies
|
|
428
|
+
from simple_resume.core.result import GenerationMetadata # noqa: PLC0415
|
|
429
|
+
from simple_resume.shell.render.latex import ( # noqa: PLC0415
|
|
430
|
+
render_resume_latex_from_data,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# Get resume data for LaTeX rendering
|
|
434
|
+
resume_data = resume.data if isinstance(resume.data, dict) else resume.raw_data
|
|
435
|
+
|
|
436
|
+
# Generate LaTeX content using the shell render function
|
|
437
|
+
tex_result = render_resume_latex_from_data(
|
|
438
|
+
resume_data,
|
|
439
|
+
paths=resume.paths,
|
|
440
|
+
template_name=render_plan.template_name or "latex/basic.tex",
|
|
441
|
+
)
|
|
442
|
+
tex_content_raw = getattr(tex_result, "tex", tex_result)
|
|
443
|
+
tex_content: str = (
|
|
444
|
+
str(tex_content_raw)
|
|
445
|
+
if not isinstance(tex_content_raw, str)
|
|
446
|
+
else tex_content_raw
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# Write .tex file (don't compile to PDF)
|
|
450
|
+
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
resolved_path.write_text(tex_content, encoding="utf-8")
|
|
452
|
+
|
|
453
|
+
# Create metadata with actual file size (after write)
|
|
454
|
+
metadata = GenerationMetadata(
|
|
455
|
+
format_type="tex",
|
|
456
|
+
template_name=render_plan.template_name or "latex/basic.tex",
|
|
457
|
+
generation_time=0.0,
|
|
458
|
+
file_size=len(tex_content.encode("utf-8")),
|
|
459
|
+
resume_name=resume.name,
|
|
460
|
+
palette_info=render_plan.palette_metadata,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
result = GenerationResult(
|
|
464
|
+
output_path=resolved_path,
|
|
465
|
+
format_type="tex",
|
|
466
|
+
metadata=metadata,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# Open file if requested
|
|
470
|
+
if open_after and result.output_path.exists():
|
|
471
|
+
shell_open_file(result.output_path, format_type="tex")
|
|
472
|
+
|
|
473
|
+
return result
|
|
474
|
+
|
|
475
|
+
except (ConfigurationError, GenerationError):
|
|
476
|
+
raise
|
|
477
|
+
except Exception as exc:
|
|
478
|
+
raise GenerationError(
|
|
479
|
+
f"Failed to generate LaTeX: {exc}",
|
|
480
|
+
format_type="tex",
|
|
481
|
+
output_path=output_path,
|
|
482
|
+
filename=resume.filename,
|
|
483
|
+
) from exc
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def render_markdown_file(
|
|
487
|
+
input_path: Path | str,
|
|
488
|
+
output_path: Path | str | None = None,
|
|
489
|
+
*,
|
|
490
|
+
open_after: bool = False,
|
|
491
|
+
) -> GenerationResult:
|
|
492
|
+
"""Render an existing Markdown file to HTML.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
input_path: Path to the .md file to render.
|
|
496
|
+
output_path: Optional output path (defaults to same name with .html).
|
|
497
|
+
open_after: Whether to open the file after generation.
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
GenerationResult with metadata and output path.
|
|
501
|
+
|
|
502
|
+
Raises:
|
|
503
|
+
GenerationError: If rendering fails.
|
|
504
|
+
|
|
505
|
+
"""
|
|
506
|
+
input_path = Path(input_path)
|
|
507
|
+
if not input_path.exists():
|
|
508
|
+
raise GenerationError(
|
|
509
|
+
f"Markdown file not found: {input_path}",
|
|
510
|
+
format_type="html",
|
|
511
|
+
output_path=output_path,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
if output_path is None:
|
|
515
|
+
resolved_output = input_path.with_suffix(".html")
|
|
516
|
+
else:
|
|
517
|
+
resolved_output = Path(output_path)
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
# Read markdown content
|
|
521
|
+
md_content = input_path.read_text(encoding="utf-8")
|
|
522
|
+
|
|
523
|
+
# Convert markdown to HTML using a simple wrapper
|
|
524
|
+
import markdown # noqa: PLC0415
|
|
525
|
+
|
|
526
|
+
html_body = markdown.markdown(md_content, extensions=["tables", "fenced_code"])
|
|
527
|
+
|
|
528
|
+
# Create full HTML document
|
|
529
|
+
body_style = (
|
|
530
|
+
"font-family: system-ui, -apple-system, sans-serif; "
|
|
531
|
+
"max-width: 800px; margin: 2em auto; padding: 0 1em; line-height: 1.6;"
|
|
532
|
+
)
|
|
533
|
+
html_content = f"""<!DOCTYPE html>
|
|
534
|
+
<html lang="en">
|
|
535
|
+
<head>
|
|
536
|
+
<meta charset="UTF-8">
|
|
537
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
538
|
+
<title>{input_path.stem}</title>
|
|
539
|
+
<style>
|
|
540
|
+
body {{ {body_style} }}
|
|
541
|
+
h1, h2, h3 {{ margin-top: 1.5em; }}
|
|
542
|
+
ul, ol {{ padding-left: 2em; }}
|
|
543
|
+
</style>
|
|
544
|
+
</head>
|
|
545
|
+
<body>
|
|
546
|
+
{html_body}
|
|
547
|
+
</body>
|
|
548
|
+
</html>"""
|
|
549
|
+
|
|
550
|
+
# Write HTML file
|
|
551
|
+
resolved_output.parent.mkdir(parents=True, exist_ok=True)
|
|
552
|
+
resolved_output.write_text(html_content, encoding="utf-8")
|
|
553
|
+
|
|
554
|
+
# Create metadata
|
|
555
|
+
from simple_resume.core.result import GenerationMetadata # noqa: PLC0415
|
|
556
|
+
|
|
557
|
+
metadata = GenerationMetadata(
|
|
558
|
+
format_type="html",
|
|
559
|
+
template_name="markdown-to-html",
|
|
560
|
+
generation_time=0.0,
|
|
561
|
+
file_size=len(html_content.encode("utf-8")),
|
|
562
|
+
resume_name=input_path.stem,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
result = GenerationResult(
|
|
566
|
+
output_path=resolved_output,
|
|
567
|
+
format_type="html",
|
|
568
|
+
metadata=metadata,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
# Open file if requested
|
|
572
|
+
if open_after and result.output_path.exists():
|
|
573
|
+
shell_open_file(result.output_path, format_type="html")
|
|
574
|
+
|
|
575
|
+
return result
|
|
576
|
+
|
|
577
|
+
except Exception as exc:
|
|
578
|
+
raise GenerationError(
|
|
579
|
+
f"Failed to render Markdown to HTML: {exc}",
|
|
580
|
+
format_type="html",
|
|
581
|
+
output_path=output_path,
|
|
582
|
+
) from exc
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def render_tex_file(
|
|
586
|
+
input_path: Path | str,
|
|
587
|
+
output_path: Path | str | None = None,
|
|
588
|
+
*,
|
|
589
|
+
open_after: bool = False,
|
|
590
|
+
) -> GenerationResult:
|
|
591
|
+
"""Render an existing LaTeX (.tex) file to PDF.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
input_path: Path to the .tex file to render.
|
|
595
|
+
output_path: Optional output path (defaults to same name with .pdf).
|
|
596
|
+
open_after: Whether to open the file after generation.
|
|
597
|
+
|
|
598
|
+
Returns:
|
|
599
|
+
GenerationResult with metadata and output path.
|
|
600
|
+
|
|
601
|
+
Raises:
|
|
602
|
+
GenerationError: If rendering fails.
|
|
603
|
+
|
|
604
|
+
"""
|
|
605
|
+
input_path = Path(input_path)
|
|
606
|
+
if not input_path.exists():
|
|
607
|
+
raise GenerationError(
|
|
608
|
+
f"LaTeX file not found: {input_path}",
|
|
609
|
+
format_type="pdf",
|
|
610
|
+
output_path=output_path,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
if output_path is None:
|
|
614
|
+
resolved_output = input_path.with_suffix(".pdf")
|
|
615
|
+
else:
|
|
616
|
+
resolved_output = Path(output_path)
|
|
617
|
+
|
|
618
|
+
try:
|
|
619
|
+
# Use the LaTeX compilation from the shell layer
|
|
620
|
+
import shutil # noqa: PLC0415
|
|
621
|
+
|
|
622
|
+
from simple_resume.shell.render.latex import ( # noqa: PLC0415
|
|
623
|
+
compile_tex_to_pdf,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
# Compile to PDF (outputs next to the .tex file)
|
|
627
|
+
resolved_output.parent.mkdir(parents=True, exist_ok=True)
|
|
628
|
+
compiled_pdf = compile_tex_to_pdf(input_path)
|
|
629
|
+
|
|
630
|
+
# Move to desired output path if different
|
|
631
|
+
if compiled_pdf != resolved_output:
|
|
632
|
+
shutil.move(str(compiled_pdf), str(resolved_output))
|
|
633
|
+
|
|
634
|
+
# Create metadata
|
|
635
|
+
from simple_resume.core.result import GenerationMetadata # noqa: PLC0415
|
|
636
|
+
|
|
637
|
+
file_size = resolved_output.stat().st_size if resolved_output.exists() else 0
|
|
638
|
+
metadata = GenerationMetadata(
|
|
639
|
+
format_type="pdf",
|
|
640
|
+
template_name="tex-to-pdf",
|
|
641
|
+
generation_time=0.0,
|
|
642
|
+
file_size=file_size,
|
|
643
|
+
resume_name=input_path.stem,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
result = GenerationResult(
|
|
647
|
+
output_path=resolved_output,
|
|
648
|
+
format_type="pdf",
|
|
649
|
+
metadata=metadata,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# Open file if requested
|
|
653
|
+
if open_after and result.output_path.exists():
|
|
654
|
+
shell_open_file(result.output_path, format_type="pdf")
|
|
655
|
+
|
|
656
|
+
return result
|
|
657
|
+
|
|
658
|
+
except LatexCompilationError as exc:
|
|
659
|
+
raise GenerationError(
|
|
660
|
+
f"LaTeX compilation failed: {exc}",
|
|
661
|
+
format_type="pdf",
|
|
662
|
+
output_path=output_path,
|
|
663
|
+
) from exc
|
|
664
|
+
except Exception as exc:
|
|
665
|
+
raise GenerationError(
|
|
666
|
+
f"Failed to render LaTeX to PDF: {exc}",
|
|
667
|
+
format_type="pdf",
|
|
668
|
+
output_path=output_path,
|
|
669
|
+
) from exc
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def generate(
|
|
673
|
+
resume: Resume,
|
|
674
|
+
format_type: OutputFormat | str = OutputFormat.PDF,
|
|
675
|
+
output_path: Path | str | None = None,
|
|
676
|
+
*,
|
|
677
|
+
open_after: bool = False,
|
|
678
|
+
) -> GenerationResult:
|
|
679
|
+
"""Generate a resume in the specified format.
|
|
680
|
+
|
|
681
|
+
This is the shell-layer dispatcher that routes to the appropriate
|
|
682
|
+
generation function based on format type.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
resume: The Resume instance to generate from.
|
|
686
|
+
format_type: Output format ('pdf', 'html', 'markdown', 'tex').
|
|
687
|
+
output_path: Optional output path.
|
|
688
|
+
open_after: Whether to open after generation.
|
|
689
|
+
|
|
690
|
+
Returns:
|
|
691
|
+
GenerationResult with metadata and operations.
|
|
692
|
+
|
|
693
|
+
Raises:
|
|
694
|
+
ValueError: If format is not supported.
|
|
695
|
+
ConfigurationError: If paths are not available.
|
|
696
|
+
GenerationError: If generation fails.
|
|
697
|
+
|
|
698
|
+
"""
|
|
699
|
+
try:
|
|
700
|
+
format_enum = (
|
|
701
|
+
format_type
|
|
702
|
+
if isinstance(format_type, OutputFormat)
|
|
703
|
+
else OutputFormat.normalize(format_type)
|
|
704
|
+
)
|
|
705
|
+
except (ValueError, TypeError):
|
|
706
|
+
raise ValueError(
|
|
707
|
+
f"Unsupported format: {format_type}. "
|
|
708
|
+
"Use 'pdf', 'html', 'markdown', or 'tex'."
|
|
709
|
+
) from None
|
|
710
|
+
|
|
711
|
+
if format_enum is OutputFormat.PDF:
|
|
712
|
+
return to_pdf(resume, output_path, open_after=open_after)
|
|
713
|
+
|
|
714
|
+
if format_enum is OutputFormat.HTML:
|
|
715
|
+
return to_html(resume, output_path, open_after=open_after)
|
|
716
|
+
|
|
717
|
+
if format_enum is OutputFormat.MARKDOWN:
|
|
718
|
+
return to_markdown(resume, output_path, open_after=open_after)
|
|
719
|
+
|
|
720
|
+
if format_enum in (OutputFormat.TEX, OutputFormat.LATEX):
|
|
721
|
+
return to_tex(resume, output_path, open_after=open_after)
|
|
722
|
+
|
|
723
|
+
raise ValueError(
|
|
724
|
+
f"Unsupported format: {format_enum.value}. "
|
|
725
|
+
"Use 'pdf', 'html', 'markdown', or 'tex'."
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
__all__ = [
|
|
730
|
+
"generate",
|
|
731
|
+
"render_markdown_file",
|
|
732
|
+
"render_tex_file",
|
|
733
|
+
"to_html",
|
|
734
|
+
"to_markdown",
|
|
735
|
+
"to_pdf",
|
|
736
|
+
"to_tex",
|
|
737
|
+
]
|