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,199 @@
1
+ """Core rendering management without external dependencies.
2
+
3
+ This module provides pure functions for template rendering setup and coordination
4
+ between different rendering backends without any I/O side effects.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from jinja2 import Environment, FileSystemLoader
13
+
14
+ from simple_resume.core.models import RenderPlan, ValidationResult
15
+
16
+
17
+ def dynamic_font_size(
18
+ text: str,
19
+ available_width_mm: float,
20
+ max_font_pt: float = 11.5,
21
+ min_font_pt: float = 8.0,
22
+ ) -> str:
23
+ """Calculate dynamic font size based on text length and available width.
24
+
25
+ This filter estimates font size needed to fit text within a given width,
26
+ scaling down proportionally from max to min size when text is too long.
27
+
28
+ Args:
29
+ text: The text to measure (combined title + company)
30
+ available_width_mm: Available width in millimeters
31
+ max_font_pt: Maximum font size in points (default 11.5pt)
32
+ min_font_pt: Minimum font size in points (default 8.0pt)
33
+
34
+ Returns:
35
+ Font size string with "pt" suffix (e.g., "10.5pt")
36
+
37
+ Note:
38
+ Uses approximate character width estimation for Avenir font.
39
+ Mixed-case text averages ~1.9mm per character at 11.5pt.
40
+ This errs on the side of reduction to prevent text wrapping.
41
+
42
+ """
43
+ # Handle edge cases: empty text or invalid dimensions
44
+ if not text or available_width_mm <= 0:
45
+ return f"{max_font_pt}pt"
46
+
47
+ text_length = len(text)
48
+
49
+ # Approximate character width at 11.5pt for Avenir font
50
+ # Mixed-case text: ~2.1mm average per character
51
+ # Errs on the side of reduction to prevent text wrapping
52
+ base_char_width_mm = 2.1
53
+
54
+ # Scale character width based on font size
55
+ char_width_at_max = base_char_width_mm * (max_font_pt / 11.5)
56
+
57
+ # Estimate text width at max font size
58
+ estimated_width_at_max = text_length * char_width_at_max
59
+
60
+ if estimated_width_at_max <= available_width_mm:
61
+ # Text fits at max size
62
+ return f"{max_font_pt}pt"
63
+
64
+ # Calculate the scaling factor needed
65
+ scale_factor = available_width_mm / estimated_width_at_max
66
+
67
+ # Apply scaling but clamp to min size
68
+ scaled_font = max_font_pt * scale_factor
69
+ final_font = max(min_font_pt, min(max_font_pt, scaled_font))
70
+
71
+ # Round to one decimal place for cleaner CSS
72
+ return f"{round(final_font, 1)}pt"
73
+
74
+
75
+ def get_template_environment(template_path: str) -> Environment:
76
+ """Create and return a Jinja2 environment for template rendering.
77
+
78
+ Args:
79
+ template_path: Path to the templates directory
80
+
81
+ Returns:
82
+ Jinja2 Environment configured for rendering
83
+
84
+ """
85
+ # Include both templates and static/css directories for CSS inlining
86
+ template_dir = Path(template_path)
87
+ css_dir = template_dir.parent / "static" / "css"
88
+ search_paths = [str(template_dir)]
89
+ if css_dir.exists():
90
+ search_paths.append(str(css_dir))
91
+
92
+ env = Environment(
93
+ loader=FileSystemLoader(search_paths),
94
+ autoescape=True,
95
+ trim_blocks=True,
96
+ lstrip_blocks=True,
97
+ )
98
+
99
+ # Register custom filters for template use
100
+ env.filters["dynamic_font_size"] = dynamic_font_size
101
+ # Also expose as a global function for use in set statements
102
+ env.globals["dynamic_font_size"] = dynamic_font_size
103
+
104
+ return env
105
+
106
+
107
+ def prepare_html_generation_request(
108
+ render_plan: RenderPlan,
109
+ output_path: Any,
110
+ **kwargs: Any,
111
+ ) -> dict[str, Any]:
112
+ """Prepare request data for HTML generation.
113
+
114
+ Args:
115
+ render_plan: The render plan to use.
116
+ output_path: Output file path.
117
+ **kwargs: Additional generation options.
118
+
119
+ Returns:
120
+ Dictionary with request data for shell layer.
121
+
122
+ """
123
+ return {
124
+ "render_plan": render_plan,
125
+ "output_path": output_path,
126
+ "filename": getattr(render_plan, "filename", None),
127
+ **kwargs,
128
+ }
129
+
130
+
131
+ def prepare_pdf_generation_request(
132
+ render_plan: RenderPlan,
133
+ output_path: Any,
134
+ open_after: bool = False,
135
+ **kwargs: Any,
136
+ ) -> dict[str, Any]:
137
+ """Prepare request data for PDF generation.
138
+
139
+ Args:
140
+ render_plan: The render plan to use.
141
+ output_path: Output file path.
142
+ open_after: Whether to open the PDF after generation.
143
+ **kwargs: Additional generation options.
144
+
145
+ Returns:
146
+ Dictionary with request data for shell layer.
147
+
148
+ """
149
+ return {
150
+ "render_plan": render_plan,
151
+ "output_path": output_path,
152
+ "open_after": open_after,
153
+ "filename": getattr(render_plan, "filename", None),
154
+ "resume_name": getattr(render_plan, "name", "resume"),
155
+ **kwargs,
156
+ }
157
+
158
+
159
+ def validate_render_plan(render_plan: RenderPlan) -> ValidationResult:
160
+ """Validate a render plan before generation.
161
+
162
+ Args:
163
+ render_plan: The render plan to validate.
164
+
165
+ Returns:
166
+ ValidationResult indicating if the plan is valid.
167
+
168
+ """
169
+ errors = []
170
+
171
+ if render_plan.mode is None:
172
+ errors.append("Render mode is required")
173
+
174
+ if render_plan.config is None:
175
+ errors.append("Render config is required")
176
+
177
+ if (
178
+ render_plan.mode is not None
179
+ and render_plan.mode.value == "html"
180
+ and render_plan.template_name is None
181
+ ):
182
+ errors.append("HTML rendering requires a template name")
183
+
184
+ return ValidationResult(
185
+ is_valid=len(errors) == 0,
186
+ errors=errors,
187
+ warnings=[],
188
+ normalized_config=None,
189
+ palette_metadata=None,
190
+ )
191
+
192
+
193
+ __all__ = [
194
+ "dynamic_font_size",
195
+ "get_template_environment",
196
+ "prepare_html_generation_request",
197
+ "prepare_pdf_generation_request",
198
+ "validate_render_plan",
199
+ ]
@@ -0,0 +1,405 @@
1
+ """Provide helpers for building render plans and validating configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import logging
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from simple_resume.core.colors import is_valid_color
13
+ from simple_resume.core.config import normalize_config
14
+ from simple_resume.core.constants import RenderMode
15
+ from simple_resume.core.exceptions import ValidationError
16
+ from simple_resume.core.markdown import render_markdown_content
17
+ from simple_resume.core.models import RenderPlan, ResumeConfig, ValidationResult
18
+ from simple_resume.core.palettes.exceptions import PaletteError
19
+ from simple_resume.core.palettes.registry import PaletteRegistry
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class RenderPlanConfig:
26
+ """Configuration for building render plans."""
27
+
28
+ name: str
29
+ mode: RenderMode
30
+ config: ResumeConfig
31
+ context: dict[str, Any] | None = None
32
+ base_path: Path | str = ""
33
+ template_name: str | None = None
34
+ palette_meta: dict[str, Any] | None = None
35
+
36
+ def __post_init__(self) -> None:
37
+ """Validate configuration after initialization."""
38
+ if self.mode is RenderMode.HTML:
39
+ if self.context is None:
40
+ raise ValueError("HTML mode requires context")
41
+ if self.template_name is None:
42
+ raise ValueError("HTML mode requires template_name")
43
+
44
+
45
+ def _validate_color_fields(config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
46
+ """Validate color fields in configuration.
47
+
48
+ Args:
49
+ config: Configuration dictionary to validate.
50
+
51
+ Returns:
52
+ Tuple of (cleaned_config, color_errors).
53
+
54
+ """
55
+ working_config = copy.deepcopy(config)
56
+ errors: list[str] = []
57
+
58
+ color_fields = [
59
+ "theme_color",
60
+ "sidebar_color",
61
+ "sidebar_text_color",
62
+ "sidebar_bold_color",
63
+ "bar_background_color",
64
+ "date2_color",
65
+ "frame_color",
66
+ "heading_icon_color",
67
+ "bold_color",
68
+ ]
69
+
70
+ for field in color_fields:
71
+ if field not in working_config:
72
+ continue
73
+ candidate = working_config.get(field)
74
+ candidate_str = str(candidate) if candidate is not None else ""
75
+ if not is_valid_color(candidate_str):
76
+ errors.append(
77
+ f"Invalid color format for '{field}': {candidate}. "
78
+ "Expected hex color like '#0395DE' or '#FFF'"
79
+ )
80
+ working_config.pop(field, None)
81
+
82
+ return working_config, errors
83
+
84
+
85
+ def _build_resume_config(normalized_config: dict[str, Any]) -> ResumeConfig:
86
+ """Build ResumeConfig from normalized configuration.
87
+
88
+ Args:
89
+ normalized_config: Normalized configuration dictionary.
90
+
91
+ Returns:
92
+ ResumeConfig instance.
93
+
94
+ """
95
+ return ResumeConfig(
96
+ page_width=normalized_config.get("page_width"),
97
+ page_height=normalized_config.get("page_height"),
98
+ sidebar_width=normalized_config.get("sidebar_width"),
99
+ output_mode=str(normalized_config.get("output_mode", "markdown"))
100
+ .strip()
101
+ .lower(),
102
+ template=normalized_config.get("template", "resume_no_bars"),
103
+ color_scheme=normalized_config.get("color_scheme", "default"),
104
+ theme_color=normalized_config.get("theme_color", "#0395DE"),
105
+ sidebar_color=normalized_config.get("sidebar_color", "#F6F6F6"),
106
+ sidebar_text_color=normalized_config.get("sidebar_text_color", "#000000"),
107
+ sidebar_bold_color=normalized_config.get("sidebar_bold_color", "#000000"),
108
+ bar_background_color=normalized_config.get("bar_background_color", "#DFDFDF"),
109
+ date2_color=normalized_config.get("date2_color", "#616161"),
110
+ frame_color=normalized_config.get("frame_color", "#757575"),
111
+ heading_icon_color=normalized_config.get("heading_icon_color", "#0395DE"),
112
+ bold_color=normalized_config.get("bold_color", "#585858"),
113
+ section_icon_circle_size=normalized_config.get("section_icon_circle_size", 7.8),
114
+ section_icon_circle_x_offset=normalized_config.get(
115
+ "section_icon_circle_x_offset", 0
116
+ ),
117
+ section_icon_design_size=normalized_config.get("section_icon_design_size", 3.5),
118
+ section_icon_design_x_offset=normalized_config.get(
119
+ "section_icon_design_x_offset", 0
120
+ ),
121
+ section_icon_design_y_offset=normalized_config.get(
122
+ "section_icon_design_y_offset", 0
123
+ ),
124
+ section_heading_text_margin=normalized_config.get(
125
+ "section_heading_text_margin", -6
126
+ ),
127
+ contact_icon_size=normalized_config.get("contact_icon_size", 5),
128
+ contact_icon_margin_top=normalized_config.get("contact_icon_margin_top", 0.5),
129
+ contact_icon_margin_right=normalized_config.get("contact_icon_margin_right", 2),
130
+ contact_icon_gap=normalized_config.get("contact_icon_gap", 4),
131
+ )
132
+
133
+
134
+ def validate_resume_config(
135
+ raw_config: dict[str, Any],
136
+ filename: str = "",
137
+ *,
138
+ registry: PaletteRegistry,
139
+ ) -> ValidationResult:
140
+ """Validate and normalize resume configuration (pure orchestration).
141
+
142
+ Args:
143
+ raw_config: Raw configuration dictionary.
144
+ filename: Source filename for error messages.
145
+ registry: Palette registry for looking up named palettes (required).
146
+
147
+ Returns:
148
+ ValidationResult with normalized config and palette metadata.
149
+
150
+ """
151
+ errors: list[str] = []
152
+ warnings: list[str] = []
153
+
154
+ try:
155
+ # Validate color fields
156
+ working_config, color_errors = _validate_color_fields(raw_config)
157
+ errors.extend(color_errors)
158
+
159
+ # Normalize configuration
160
+ normalized_config, palette_meta = normalize_config(
161
+ working_config, filename=filename, registry=registry
162
+ )
163
+
164
+ # Build configuration object
165
+ config = _build_resume_config(normalized_config)
166
+
167
+ if errors:
168
+ return ValidationResult(
169
+ is_valid=False,
170
+ errors=errors,
171
+ warnings=warnings,
172
+ normalized_config=None,
173
+ palette_metadata=None,
174
+ )
175
+
176
+ return ValidationResult(
177
+ is_valid=True,
178
+ errors=[],
179
+ warnings=warnings,
180
+ normalized_config=config,
181
+ palette_metadata=palette_meta,
182
+ )
183
+
184
+ except ValueError as exc:
185
+ errors.append(str(exc))
186
+ return ValidationResult(is_valid=False, errors=errors, warnings=warnings)
187
+ except (KeyError, TypeError, AttributeError) as exc:
188
+ errors.append(f"Configuration error: {exc}")
189
+ return ValidationResult(is_valid=False, errors=errors, warnings=warnings)
190
+ except PaletteError as exc:
191
+ errors.append(f"Palette error: {exc}")
192
+ return ValidationResult(is_valid=False, errors=errors, warnings=warnings)
193
+
194
+
195
+ def validate_resume_config_or_raise(
196
+ raw_config: dict[str, Any],
197
+ filename: str = "",
198
+ *,
199
+ registry: PaletteRegistry | None = None,
200
+ ) -> ResumeConfig:
201
+ """Validate configuration and raise `ValidationError` on failure."""
202
+ if registry is None:
203
+ registry = PaletteRegistry()
204
+ result = validate_resume_config(raw_config, filename, registry=registry)
205
+ if not result.is_valid:
206
+ raise ValidationError(
207
+ f"Configuration validation failed: {result.errors}",
208
+ errors=result.errors,
209
+ filename=filename,
210
+ )
211
+
212
+ if result.normalized_config is None: # pragma: no cover - defensive branch
213
+ raise ValidationError(
214
+ "Configuration validation failed: No normalized config produced",
215
+ errors=["Internal validation error"],
216
+ filename=filename,
217
+ )
218
+
219
+ return result.normalized_config
220
+
221
+
222
+ def normalize_with_palette_fallback(
223
+ raw_config: dict[str, Any],
224
+ *,
225
+ registry: PaletteRegistry,
226
+ palette_meta_source: dict[str, Any] | None = None,
227
+ ) -> tuple[dict[str, Any], Any, dict[str, Any]]:
228
+ """Normalize a raw config while handling palette generation failures (pure).
229
+
230
+ Args:
231
+ raw_config: Raw configuration dictionary.
232
+ registry: Palette registry for looking up named palettes (required).
233
+ palette_meta_source: Optional source for fallback palette metadata.
234
+
235
+ Returns:
236
+ Tuple of (normalized_config, palette_metadata, config_for_validation).
237
+
238
+ """
239
+ config_for_validation = raw_config
240
+
241
+ try:
242
+ normalized_config_dict, palette_meta = normalize_config(
243
+ raw_config, registry=registry
244
+ )
245
+ return normalized_config_dict, palette_meta, config_for_validation
246
+ except PaletteError as exc:
247
+ palette_name = raw_config.get("palette", "unknown")
248
+ logger.warning(
249
+ "Palette error (%s), using default palette. Original palette config: %s",
250
+ type(exc).__name__,
251
+ palette_name,
252
+ )
253
+ # User-visible warning (CLI users need to know about color fallback)
254
+ print(
255
+ f"Warning: Palette '{palette_name}' not found or invalid. "
256
+ "Using default colors. Check your palette name or file.",
257
+ file=sys.stderr,
258
+ )
259
+ fallback_meta = None
260
+ if isinstance(palette_meta_source, dict):
261
+ fallback_meta = palette_meta_source.get("palette")
262
+
263
+ cleaned_config = copy.deepcopy(raw_config)
264
+ cleaned_config.pop("palette", None)
265
+ try:
266
+ normalized_config_dict, _ = normalize_config(
267
+ cleaned_config, registry=registry
268
+ )
269
+ except Exception as fallback_exc:
270
+ logger.error("Fallback normalization also failed: %s", fallback_exc)
271
+ raise
272
+
273
+ return normalized_config_dict, fallback_meta, cleaned_config
274
+
275
+
276
+ def transform_for_mode(
277
+ source_yaml_content: dict[str, Any], mode: RenderMode
278
+ ) -> dict[str, Any]:
279
+ """Transform YAML content based on render mode."""
280
+ if mode is RenderMode.LATEX:
281
+ return copy.deepcopy(source_yaml_content)
282
+
283
+ return render_markdown_content(source_yaml_content)
284
+
285
+
286
+ def build_render_plan(plan_config: RenderPlanConfig) -> RenderPlan:
287
+ """Build the final `RenderPlan` based on resolved mode and context.
288
+
289
+ Args:
290
+ plan_config: Configuration for the render plan. Note that RenderPlanConfig
291
+ performs validation in __post_init__, so invalid HTML configurations
292
+ will raise ValueError at construction time.
293
+
294
+ Returns:
295
+ Configured RenderPlan object.
296
+
297
+ Raises:
298
+ ValueError: If HTML render plan is missing required context or template name.
299
+ This is a defensive check; RenderPlanConfig validates this at construction.
300
+
301
+ """
302
+ if plan_config.mode is RenderMode.LATEX:
303
+ return RenderPlan(
304
+ name=plan_config.name,
305
+ mode=RenderMode.LATEX,
306
+ config=plan_config.config,
307
+ base_path=plan_config.base_path,
308
+ tex=None,
309
+ palette_metadata=plan_config.palette_meta,
310
+ )
311
+
312
+ if plan_config.context is None:
313
+ raise ValueError("HTML render plans require a context dictionary")
314
+
315
+ if plan_config.template_name is None:
316
+ raise ValueError("HTML render plans require a template name")
317
+
318
+ return RenderPlan(
319
+ name=plan_config.name,
320
+ mode=RenderMode.HTML,
321
+ config=plan_config.config,
322
+ template_name=plan_config.template_name,
323
+ context=plan_config.context,
324
+ base_path=plan_config.base_path,
325
+ palette_metadata=plan_config.palette_meta,
326
+ )
327
+
328
+
329
+ def prepare_render_data(
330
+ source_yaml_content: dict[str, Any],
331
+ *,
332
+ preview: bool = False,
333
+ base_path: Path | str = "",
334
+ registry: PaletteRegistry | None = None,
335
+ ) -> RenderPlan:
336
+ """Transform raw resume data into a render plan."""
337
+ raw_config = source_yaml_content.get("config")
338
+ if not isinstance(raw_config, dict) or not raw_config:
339
+ raise ValueError("Invalid resume config: missing or malformed config section")
340
+
341
+ # Create default registry if none provided
342
+ if registry is None:
343
+ registry = PaletteRegistry()
344
+
345
+ normalized_config_dict, palette_meta, config_for_validation = (
346
+ normalize_with_palette_fallback(
347
+ raw_config,
348
+ registry=registry,
349
+ palette_meta_source=source_yaml_content.get("meta"),
350
+ )
351
+ )
352
+
353
+ config = validate_resume_config_or_raise(config_for_validation, registry=registry)
354
+
355
+ mode: RenderMode = (
356
+ RenderMode.LATEX if config.output_mode == "latex" else RenderMode.HTML
357
+ )
358
+
359
+ transformed_data = transform_for_mode(source_yaml_content, mode)
360
+
361
+ name = transformed_data.get("full_name", "resume")
362
+
363
+ if mode is RenderMode.LATEX:
364
+ plan_config = RenderPlanConfig(
365
+ name=name,
366
+ mode=mode,
367
+ config=config,
368
+ context=None,
369
+ base_path=base_path,
370
+ palette_meta=palette_meta,
371
+ )
372
+ return build_render_plan(plan_config)
373
+
374
+ template = transformed_data.get("template", "resume_no_bars")
375
+ template_name = f"html/{template}.html"
376
+
377
+ context = dict(transformed_data)
378
+ context["resume_config"] = normalized_config_dict or {}
379
+ context["preview"] = preview
380
+
381
+ # Merge normalized config properties into top-level context for template access
382
+ if normalized_config_dict:
383
+ context.update(normalized_config_dict)
384
+
385
+ plan_config = RenderPlanConfig(
386
+ name=name,
387
+ mode=mode,
388
+ config=config,
389
+ context=context,
390
+ base_path=base_path,
391
+ template_name=template_name,
392
+ palette_meta=palette_meta,
393
+ )
394
+ return build_render_plan(plan_config)
395
+
396
+
397
+ __all__ = [
398
+ "build_render_plan",
399
+ "normalize_with_palette_fallback",
400
+ "prepare_render_data",
401
+ "RenderPlanConfig",
402
+ "transform_for_mode",
403
+ "validate_resume_config",
404
+ "validate_resume_config_or_raise",
405
+ ]