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,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
+ ]