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