sync-cv-formatter 1.2.0__tar.gz
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.
- sync_cv_formatter-1.2.0/PKG-INFO +90 -0
- sync_cv_formatter-1.2.0/README.md +69 -0
- sync_cv_formatter-1.2.0/pyproject.toml +47 -0
- sync_cv_formatter-1.2.0/setup.cfg +4 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/__init__.py +13 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/renderers/__init__.py +4 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/renderers/html_renderer.py +211 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/renderers/pdf_renderer.py +42 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/schemas/__init__.py +27 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/schemas/resume_document.py +100 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/_base_head.html.j2 +10 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/_macros.html.j2 +43 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/_sections.html.j2 +57 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/_sections_experience_first.html.j2 +57 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/_sections_tail.html.j2 +95 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/classic/styles.css +217 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/classic/template.html.j2 +16 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/compact/styles.css +245 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/compact/template.html.j2 +21 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/executive/styles.css +229 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/executive/template.html.j2 +19 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/modern/styles.css +253 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/modern/template.html.j2 +22 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/professional/styles.css +258 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter/templates/html/professional/template.html.j2 +19 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter.egg-info/PKG-INFO +90 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter.egg-info/SOURCES.txt +29 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter.egg-info/dependency_links.txt +1 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter.egg-info/requires.txt +2 -0
- sync_cv_formatter-1.2.0/src/sync_cv_formatter.egg-info/top_level.txt +1 -0
- sync_cv_formatter-1.2.0/tests/test_html_renderer.py +78 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sync_cv_formatter
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: CV/resume HTML and PDF rendering with hiring-manager-focused templates
|
|
5
|
+
Author: SyncQues
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SyncQues/CV-Formatter
|
|
8
|
+
Project-URL: Repository, https://github.com/SyncQues/CV-Formatter
|
|
9
|
+
Project-URL: Issues, https://github.com/SyncQues/CV-Formatter/issues
|
|
10
|
+
Keywords: resume,cv,pdf,html,jinja2
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: jinja2>=3.1.6
|
|
20
|
+
Requires-Dist: pydantic>=2.12.3
|
|
21
|
+
|
|
22
|
+
# CV Formatter
|
|
23
|
+
|
|
24
|
+
Public Python package for resume/CV HTML and PDF rendering — single source of truth for SyncQues resume templates, schemas, and rendering.
|
|
25
|
+
|
|
26
|
+
**Repository:** https://github.com/SyncQues/CV-Formatter
|
|
27
|
+
**PyPI:** https://pypi.org/project/sync_cv_formatter/
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install sync_cv_formatter
|
|
33
|
+
# or
|
|
34
|
+
uv add sync_cv_formatter
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Consumers
|
|
38
|
+
|
|
39
|
+
- `SyncQues-Backend` — recompile and live preview
|
|
40
|
+
- `SyncQues-Resume` — initial resume generation
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from sync_cv_formatter import ResumeDocument, populate_html_template, render_html_to_pdf
|
|
46
|
+
|
|
47
|
+
document = ResumeDocument.model_validate(content_json)
|
|
48
|
+
html = populate_html_template(document, template_id="modern")
|
|
49
|
+
pdf_bytes = render_html_to_pdf(html)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Templates
|
|
53
|
+
|
|
54
|
+
| ID | Style |
|
|
55
|
+
|----|-------|
|
|
56
|
+
| `professional` | Centered serif, traditional |
|
|
57
|
+
| `executive` | Experience-first, navy serif/sans |
|
|
58
|
+
| `modern` | Inter sans-serif, teal accent |
|
|
59
|
+
| `classic` | Times New Roman, ATS-maximum |
|
|
60
|
+
| `compact` | Dense one-page, IBM Plex |
|
|
61
|
+
|
|
62
|
+
Legacy `creative` maps to `modern`.
|
|
63
|
+
|
|
64
|
+
## Local development
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv sync --dev
|
|
68
|
+
uv run pytest
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Editable install from a checkout:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv add --editable ../CV-Formatter
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Release process
|
|
78
|
+
|
|
79
|
+
1. Change templates/code in this repo
|
|
80
|
+
2. Bump `version` in `pyproject.toml`
|
|
81
|
+
3. Run `uv run pytest`
|
|
82
|
+
4. Commit, tag (`git tag v1.2.1`), push tag
|
|
83
|
+
5. GitHub Actions publishes to PyPI (or `uv build && uv publish`)
|
|
84
|
+
6. Bump pin in Backend + Resume `pyproject.toml` and `uv lock`
|
|
85
|
+
|
|
86
|
+
## Versioning
|
|
87
|
+
|
|
88
|
+
- **MAJOR** — breaking `content_json` schema changes
|
|
89
|
+
- **MINOR** — template/CSS changes or new optional fields
|
|
90
|
+
- **PATCH** — bug fixes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# CV Formatter
|
|
2
|
+
|
|
3
|
+
Public Python package for resume/CV HTML and PDF rendering — single source of truth for SyncQues resume templates, schemas, and rendering.
|
|
4
|
+
|
|
5
|
+
**Repository:** https://github.com/SyncQues/CV-Formatter
|
|
6
|
+
**PyPI:** https://pypi.org/project/sync_cv_formatter/
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install sync_cv_formatter
|
|
12
|
+
# or
|
|
13
|
+
uv add sync_cv_formatter
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Consumers
|
|
17
|
+
|
|
18
|
+
- `SyncQues-Backend` — recompile and live preview
|
|
19
|
+
- `SyncQues-Resume` — initial resume generation
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from sync_cv_formatter import ResumeDocument, populate_html_template, render_html_to_pdf
|
|
25
|
+
|
|
26
|
+
document = ResumeDocument.model_validate(content_json)
|
|
27
|
+
html = populate_html_template(document, template_id="modern")
|
|
28
|
+
pdf_bytes = render_html_to_pdf(html)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Templates
|
|
32
|
+
|
|
33
|
+
| ID | Style |
|
|
34
|
+
|----|-------|
|
|
35
|
+
| `professional` | Centered serif, traditional |
|
|
36
|
+
| `executive` | Experience-first, navy serif/sans |
|
|
37
|
+
| `modern` | Inter sans-serif, teal accent |
|
|
38
|
+
| `classic` | Times New Roman, ATS-maximum |
|
|
39
|
+
| `compact` | Dense one-page, IBM Plex |
|
|
40
|
+
|
|
41
|
+
Legacy `creative` maps to `modern`.
|
|
42
|
+
|
|
43
|
+
## Local development
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv sync --dev
|
|
47
|
+
uv run pytest
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Editable install from a checkout:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv add --editable ../CV-Formatter
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Release process
|
|
57
|
+
|
|
58
|
+
1. Change templates/code in this repo
|
|
59
|
+
2. Bump `version` in `pyproject.toml`
|
|
60
|
+
3. Run `uv run pytest`
|
|
61
|
+
4. Commit, tag (`git tag v1.2.1`), push tag
|
|
62
|
+
5. GitHub Actions publishes to PyPI (or `uv build && uv publish`)
|
|
63
|
+
6. Bump pin in Backend + Resume `pyproject.toml` and `uv lock`
|
|
64
|
+
|
|
65
|
+
## Versioning
|
|
66
|
+
|
|
67
|
+
- **MAJOR** — breaking `content_json` schema changes
|
|
68
|
+
- **MINOR** — template/CSS changes or new optional fields
|
|
69
|
+
- **PATCH** — bug fixes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sync_cv_formatter"
|
|
3
|
+
version = "1.2.0"
|
|
4
|
+
description = "CV/resume HTML and PDF rendering with hiring-manager-focused templates"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "SyncQues" }]
|
|
9
|
+
keywords = ["resume", "cv", "pdf", "html", "jinja2"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
"Topic :: Text Processing :: Markup :: HTML",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"jinja2>=3.1.6",
|
|
20
|
+
"pydantic>=2.12.3",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/SyncQues/CV-Formatter"
|
|
25
|
+
Repository = "https://github.com/SyncQues/CV-Formatter"
|
|
26
|
+
Issues = "https://github.com/SyncQues/CV-Formatter/issues"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"build>=1.2",
|
|
31
|
+
"pytest>=8.3",
|
|
32
|
+
"twine>=6.1",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["setuptools>=61"]
|
|
37
|
+
build-backend = "setuptools.build_meta"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.package-data]
|
|
43
|
+
sync_cv_formatter = ["templates/html/**/*"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from sync_cv_formatter.renderers.html_renderer import (
|
|
2
|
+
populate_html_template,
|
|
3
|
+
save_html_file,
|
|
4
|
+
)
|
|
5
|
+
from sync_cv_formatter.renderers.pdf_renderer import render_html_to_pdf
|
|
6
|
+
from sync_cv_formatter.schemas.resume_document import ResumeDocument
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ResumeDocument",
|
|
10
|
+
"populate_html_template",
|
|
11
|
+
"render_html_to_pdf",
|
|
12
|
+
"save_html_file",
|
|
13
|
+
]
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
6
|
+
|
|
7
|
+
from sync_cv_formatter.schemas.resume_document import ResumeDocument, ResumeTemplateId
|
|
8
|
+
|
|
9
|
+
_PKG_DIR = Path(__file__).resolve().parent.parent
|
|
10
|
+
HTML_TEMPLATES_DIR = _PKG_DIR / "templates" / "html"
|
|
11
|
+
|
|
12
|
+
_MONTHS = (
|
|
13
|
+
"Jan",
|
|
14
|
+
"Feb",
|
|
15
|
+
"Mar",
|
|
16
|
+
"Apr",
|
|
17
|
+
"May",
|
|
18
|
+
"Jun",
|
|
19
|
+
"Jul",
|
|
20
|
+
"Aug",
|
|
21
|
+
"Sep",
|
|
22
|
+
"Oct",
|
|
23
|
+
"Nov",
|
|
24
|
+
"Dec",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _format_resume_date(value: str | None) -> str:
|
|
29
|
+
if not value:
|
|
30
|
+
return "Present"
|
|
31
|
+
|
|
32
|
+
normalized = str(value).strip()
|
|
33
|
+
if normalized.lower() == "present":
|
|
34
|
+
return "Present"
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
parsed = datetime.fromisoformat(normalized.replace("Z", "+00:00"))
|
|
38
|
+
return f"{_MONTHS[parsed.month - 1]} {parsed.year}"
|
|
39
|
+
except ValueError:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
if len(normalized) >= 7 and normalized[4] == "-":
|
|
43
|
+
try:
|
|
44
|
+
year = int(normalized[:4])
|
|
45
|
+
month = int(normalized[5:7])
|
|
46
|
+
if 1 <= month <= 12:
|
|
47
|
+
return f"{_MONTHS[month - 1]} {year}"
|
|
48
|
+
except ValueError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
return normalized
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _format_external_url(url: str | None) -> str:
|
|
55
|
+
if not url:
|
|
56
|
+
return ""
|
|
57
|
+
normalized = str(url).strip()
|
|
58
|
+
return normalized if normalized.startswith("http") else f"https://{normalized}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resume_link_domain(url: str | None) -> str | None:
|
|
62
|
+
href = _format_external_url(url)
|
|
63
|
+
if not href:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
hostname = urlparse(href).hostname or ""
|
|
67
|
+
return hostname.removeprefix("www.") or None
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resume_favicon_url(url: str | None) -> str:
|
|
73
|
+
domain = _resume_link_domain(url)
|
|
74
|
+
if not domain:
|
|
75
|
+
return ""
|
|
76
|
+
return f"https://www.google.com/s2/favicons?domain={domain}&sz=32"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_PLATFORM_LABELS = {
|
|
80
|
+
"linkedin": "LinkedIn",
|
|
81
|
+
"github": "GitHub",
|
|
82
|
+
"gitlab": "GitLab",
|
|
83
|
+
"twitter": "Twitter",
|
|
84
|
+
"x": "X",
|
|
85
|
+
"youtube": "YouTube",
|
|
86
|
+
"instagram": "Instagram",
|
|
87
|
+
"facebook": "Facebook",
|
|
88
|
+
"portfolio": "Portfolio",
|
|
89
|
+
"website": "Website",
|
|
90
|
+
"syncques": "SyncQues",
|
|
91
|
+
"leetcode": "LeetCode",
|
|
92
|
+
"stackoverflow": "Stack Overflow",
|
|
93
|
+
"medium": "Medium",
|
|
94
|
+
"behance": "Behance",
|
|
95
|
+
"dribbble": "Dribbble",
|
|
96
|
+
"credential": "View Credential",
|
|
97
|
+
"live": "Live",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_DOMAIN_LABEL_PATTERNS: list[tuple[str, str]] = [
|
|
101
|
+
("linkedin.com", "LinkedIn"),
|
|
102
|
+
("github.com", "GitHub"),
|
|
103
|
+
("gitlab.com", "GitLab"),
|
|
104
|
+
("twitter.com", "X"),
|
|
105
|
+
("x.com", "X"),
|
|
106
|
+
("youtube.com", "YouTube"),
|
|
107
|
+
("youtu.be", "YouTube"),
|
|
108
|
+
("instagram.com", "Instagram"),
|
|
109
|
+
("facebook.com", "Facebook"),
|
|
110
|
+
("fb.com", "Facebook"),
|
|
111
|
+
("syncques.com", "SyncQues"),
|
|
112
|
+
("leetcode.com", "LeetCode"),
|
|
113
|
+
("stackoverflow.com", "Stack Overflow"),
|
|
114
|
+
("medium.com", "Medium"),
|
|
115
|
+
("behance.net", "Behance"),
|
|
116
|
+
("dribbble.com", "Dribbble"),
|
|
117
|
+
("credly.com", "Credly"),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resume_link_label(
|
|
122
|
+
platform_or_kind: str, url: str | None = None, fallback: str | None = None
|
|
123
|
+
) -> str:
|
|
124
|
+
platform_key = platform_or_kind.lower().replace(" ", "_")
|
|
125
|
+
if platform_key in _PLATFORM_LABELS:
|
|
126
|
+
return _PLATFORM_LABELS[platform_key]
|
|
127
|
+
|
|
128
|
+
href = _format_external_url(url)
|
|
129
|
+
if href:
|
|
130
|
+
lower_href = href.lower()
|
|
131
|
+
for domain, label in _DOMAIN_LABEL_PATTERNS:
|
|
132
|
+
if domain in lower_href:
|
|
133
|
+
return label
|
|
134
|
+
|
|
135
|
+
domain = _resume_link_domain(href)
|
|
136
|
+
if domain:
|
|
137
|
+
base = domain.split(".")[0]
|
|
138
|
+
return base[:1].upper() + base[1:]
|
|
139
|
+
|
|
140
|
+
if fallback:
|
|
141
|
+
return fallback
|
|
142
|
+
|
|
143
|
+
return platform_or_kind.replace("_", " ").title()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
TEMPLATE_DIRS: dict[ResumeTemplateId, str] = {
|
|
147
|
+
"professional": "professional",
|
|
148
|
+
"executive": "executive",
|
|
149
|
+
"modern": "modern",
|
|
150
|
+
"classic": "classic",
|
|
151
|
+
"compact": "compact",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_LEGACY_TEMPLATE_ALIASES: dict[str, ResumeTemplateId] = {
|
|
155
|
+
"creative": "modern",
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _get_template_env(template_id: ResumeTemplateId) -> Environment:
|
|
160
|
+
template_dir = HTML_TEMPLATES_DIR / TEMPLATE_DIRS[template_id]
|
|
161
|
+
env = Environment(
|
|
162
|
+
loader=FileSystemLoader(
|
|
163
|
+
[str(template_dir), str(HTML_TEMPLATES_DIR)],
|
|
164
|
+
),
|
|
165
|
+
autoescape=select_autoescape(["html", "xml"]),
|
|
166
|
+
)
|
|
167
|
+
env.filters["format_resume_date"] = _format_resume_date
|
|
168
|
+
env.filters["format_external_url"] = _format_external_url
|
|
169
|
+
env.filters["resume_favicon_url"] = _resume_favicon_url
|
|
170
|
+
env.globals["resume_link_label"] = _resume_link_label
|
|
171
|
+
return env
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _load_css(template_id: ResumeTemplateId) -> str:
|
|
175
|
+
css_path = HTML_TEMPLATES_DIR / TEMPLATE_DIRS[template_id] / "styles.css"
|
|
176
|
+
return css_path.read_text(encoding="utf-8")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _resolve_template_id(
|
|
180
|
+
template_id: str | ResumeTemplateId | None,
|
|
181
|
+
*,
|
|
182
|
+
fallback: ResumeTemplateId = "professional",
|
|
183
|
+
) -> ResumeTemplateId:
|
|
184
|
+
normalized = str(template_id or fallback).strip().lower()
|
|
185
|
+
if normalized in _LEGACY_TEMPLATE_ALIASES:
|
|
186
|
+
return _LEGACY_TEMPLATE_ALIASES[normalized]
|
|
187
|
+
if normalized in TEMPLATE_DIRS:
|
|
188
|
+
return normalized # type: ignore[return-value]
|
|
189
|
+
return fallback
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def populate_html_template(
|
|
193
|
+
document: ResumeDocument,
|
|
194
|
+
template_id: ResumeTemplateId | None = None,
|
|
195
|
+
*,
|
|
196
|
+
interactive: bool = False,
|
|
197
|
+
) -> str:
|
|
198
|
+
selected_template = _resolve_template_id(template_id or document.template_id)
|
|
199
|
+
env = _get_template_env(selected_template)
|
|
200
|
+
template = env.get_template("template.html.j2")
|
|
201
|
+
return template.render(
|
|
202
|
+
document=document,
|
|
203
|
+
css_content=_load_css(selected_template),
|
|
204
|
+
interactive=interactive,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def save_html_file(content: str, output_path: str) -> str:
|
|
209
|
+
html_path = output_path.replace(".pdf", ".html")
|
|
210
|
+
Path(html_path).write_text(content, encoding="utf-8")
|
|
211
|
+
return html_path
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def render_html_to_pdf(html_file: str, output_dir: str | None = None) -> tuple[bool, str]:
|
|
5
|
+
html_path = Path(html_file)
|
|
6
|
+
|
|
7
|
+
if not html_path.exists():
|
|
8
|
+
return False, f"HTML file not found: {html_file}"
|
|
9
|
+
|
|
10
|
+
if output_dir is None:
|
|
11
|
+
output_dir = html_path.parent
|
|
12
|
+
|
|
13
|
+
output_dir = Path(output_dir)
|
|
14
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
pdf_path = output_dir / html_path.with_suffix(".pdf").name
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from playwright.sync_api import sync_playwright
|
|
19
|
+
except ImportError:
|
|
20
|
+
return False, "Playwright is not installed. Run: playwright install chromium"
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
file_url = html_path.resolve().as_uri()
|
|
24
|
+
with sync_playwright() as playwright:
|
|
25
|
+
browser = playwright.chromium.launch(headless=True)
|
|
26
|
+
page = browser.new_page()
|
|
27
|
+
page.goto(file_url, wait_until="networkidle")
|
|
28
|
+
page.pdf(
|
|
29
|
+
path=str(pdf_path),
|
|
30
|
+
format="A4",
|
|
31
|
+
print_background=True,
|
|
32
|
+
prefer_css_page_size=True,
|
|
33
|
+
margin={"top": "0", "right": "0", "bottom": "0", "left": "0"},
|
|
34
|
+
)
|
|
35
|
+
browser.close()
|
|
36
|
+
|
|
37
|
+
if pdf_path.exists():
|
|
38
|
+
return True, f"PDF created successfully: {pdf_path}"
|
|
39
|
+
|
|
40
|
+
return False, "PDF file was not created by Playwright"
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
return False, f"HTML to PDF rendering failed: {exc}"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from sync_cv_formatter.schemas.resume_document import (
|
|
2
|
+
AchievementItem,
|
|
3
|
+
BasicsSection,
|
|
4
|
+
EducationItem,
|
|
5
|
+
ExperienceItem,
|
|
6
|
+
ProjectItem,
|
|
7
|
+
ResumeDocument,
|
|
8
|
+
ResumeDocumentMetadata,
|
|
9
|
+
ResumeSections,
|
|
10
|
+
ResumeTemplateId,
|
|
11
|
+
ResumeTypeValue,
|
|
12
|
+
SkillsSection,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AchievementItem",
|
|
17
|
+
"BasicsSection",
|
|
18
|
+
"EducationItem",
|
|
19
|
+
"ExperienceItem",
|
|
20
|
+
"ProjectItem",
|
|
21
|
+
"ResumeDocument",
|
|
22
|
+
"ResumeDocumentMetadata",
|
|
23
|
+
"ResumeSections",
|
|
24
|
+
"ResumeTemplateId",
|
|
25
|
+
"ResumeTypeValue",
|
|
26
|
+
"SkillsSection",
|
|
27
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
ResumeTemplateId = Literal["professional", "executive", "modern", "classic", "compact"]
|
|
7
|
+
ResumeTypeValue = Literal["standard", "ats_optimized"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BasicsSection(BaseModel):
|
|
11
|
+
full_name: str = ""
|
|
12
|
+
email: str = ""
|
|
13
|
+
phone: str | None = None
|
|
14
|
+
location: str | None = None
|
|
15
|
+
summary: str | None = None
|
|
16
|
+
syncques_url: str | None = None
|
|
17
|
+
social_links: dict[str, str] = Field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SkillsSection(BaseModel):
|
|
21
|
+
flat: list[str] = Field(default_factory=list)
|
|
22
|
+
categorized: dict[str, list[str]] = Field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ExperienceItem(BaseModel):
|
|
26
|
+
title: str = ""
|
|
27
|
+
company: str = ""
|
|
28
|
+
location: str | None = None
|
|
29
|
+
start_date: str | None = None
|
|
30
|
+
end_date: str | None = "Present"
|
|
31
|
+
description: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EducationItem(BaseModel):
|
|
35
|
+
institution: str = ""
|
|
36
|
+
degree: str = ""
|
|
37
|
+
field_of_study: str | None = None
|
|
38
|
+
location: str | None = None
|
|
39
|
+
start_date: str | None = None
|
|
40
|
+
end_date: str | None = "Present"
|
|
41
|
+
gpa: str | None = None
|
|
42
|
+
description: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ProjectItem(BaseModel):
|
|
46
|
+
title: str = ""
|
|
47
|
+
description: str = ""
|
|
48
|
+
technologies: list[str] = Field(default_factory=list)
|
|
49
|
+
project_url: str | None = None
|
|
50
|
+
repository_url: str | None = None
|
|
51
|
+
start_date: str | None = None
|
|
52
|
+
end_date: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AchievementItem(BaseModel):
|
|
56
|
+
title: str = ""
|
|
57
|
+
organization: str | None = None
|
|
58
|
+
description: str | None = None
|
|
59
|
+
date_earned: str | None = None
|
|
60
|
+
credential_url: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ResumeSections(BaseModel):
|
|
64
|
+
skills: SkillsSection = Field(default_factory=SkillsSection)
|
|
65
|
+
experience: list[ExperienceItem] = Field(default_factory=list)
|
|
66
|
+
education: list[EducationItem] = Field(default_factory=list)
|
|
67
|
+
projects: list[ProjectItem] = Field(default_factory=list)
|
|
68
|
+
achievements: list[AchievementItem] = Field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ResumeDocumentMetadata(BaseModel):
|
|
72
|
+
generated_at: str
|
|
73
|
+
resume_type: ResumeTypeValue = "standard"
|
|
74
|
+
job_title: str | None = None
|
|
75
|
+
job_description: str | None = None
|
|
76
|
+
last_edited_at: str | None = None
|
|
77
|
+
content_version: int = 1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ResumeDocument(BaseModel):
|
|
81
|
+
schema_version: str = "1.0"
|
|
82
|
+
template_id: ResumeTemplateId = "professional"
|
|
83
|
+
basics: BasicsSection = Field(default_factory=BasicsSection)
|
|
84
|
+
sections: ResumeSections = Field(default_factory=ResumeSections)
|
|
85
|
+
metadata: ResumeDocumentMetadata
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def default_metadata(
|
|
89
|
+
cls,
|
|
90
|
+
resume_type: ResumeTypeValue = "standard",
|
|
91
|
+
job_title: str | None = None,
|
|
92
|
+
job_description: str | None = None,
|
|
93
|
+
) -> ResumeDocumentMetadata:
|
|
94
|
+
return ResumeDocumentMetadata(
|
|
95
|
+
generated_at=datetime.now(timezone.utc).isoformat(),
|
|
96
|
+
resume_type=resume_type,
|
|
97
|
+
job_title=job_title,
|
|
98
|
+
job_description=job_description,
|
|
99
|
+
content_version=1,
|
|
100
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<meta charset="UTF-8" />
|
|
2
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
3
|
+
<title>{{ document.basics.full_name }} — Resume</title>
|
|
4
|
+
<style>{{ css_content }}</style>
|
|
5
|
+
{% if interactive %}
|
|
6
|
+
<style>
|
|
7
|
+
[data-resume-section] { cursor: pointer; border-radius: 2px; }
|
|
8
|
+
[data-resume-section]:hover { background: rgba(0, 51, 153, 0.05); }
|
|
9
|
+
</style>
|
|
10
|
+
{% endif %}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{% macro resume_contact(document) %}
|
|
2
|
+
{% set ns = namespace(first=true) %}
|
|
3
|
+
{% if document.basics.email %}
|
|
4
|
+
{% if not ns.first %}<span class="contact-sep">·</span>{% endif %}
|
|
5
|
+
<a href="mailto:{{ document.basics.email }}">{{ document.basics.email }}</a>
|
|
6
|
+
{% set ns.first = false %}
|
|
7
|
+
{% endif %}
|
|
8
|
+
{% if document.basics.phone %}
|
|
9
|
+
{% if not ns.first %}<span class="contact-sep">·</span>{% endif %}
|
|
10
|
+
<a href="tel:{{ document.basics.phone }}">{{ document.basics.phone }}</a>
|
|
11
|
+
{% set ns.first = false %}
|
|
12
|
+
{% endif %}
|
|
13
|
+
{% if document.basics.location %}
|
|
14
|
+
{% if not ns.first %}<span class="contact-sep">·</span>{% endif %}
|
|
15
|
+
<span>{{ document.basics.location }}</span>
|
|
16
|
+
{% set ns.first = false %}
|
|
17
|
+
{% endif %}
|
|
18
|
+
{% if document.basics.syncques_url %}
|
|
19
|
+
{% if not ns.first %}<span class="contact-sep">·</span>{% endif %}
|
|
20
|
+
{{ external_link('syncques', document.basics.syncques_url) }}
|
|
21
|
+
{% set ns.first = false %}
|
|
22
|
+
{% endif %}
|
|
23
|
+
{% for platform, url in document.basics.social_links.items() %}
|
|
24
|
+
{% if url %}
|
|
25
|
+
{% if not ns.first %}<span class="contact-sep">·</span>{% endif %}
|
|
26
|
+
{{ external_link(platform, url) }}
|
|
27
|
+
{% set ns.first = false %}
|
|
28
|
+
{% endif %}
|
|
29
|
+
{% endfor %}
|
|
30
|
+
{% endmacro %}
|
|
31
|
+
|
|
32
|
+
{% macro external_link(platform, url, fallback='') %}
|
|
33
|
+
{% set href = url | format_external_url %}
|
|
34
|
+
{% if href %}
|
|
35
|
+
<a href="{{ href }}" class="resume-external-link">
|
|
36
|
+
{% set favicon = url | resume_favicon_url %}
|
|
37
|
+
{% if favicon %}
|
|
38
|
+
<img src="{{ favicon }}" alt="" class="resume-link-icon" width="12" height="12" />
|
|
39
|
+
{% endif %}
|
|
40
|
+
<span>{{ resume_link_label(platform, url, fallback) }}</span>
|
|
41
|
+
</a>
|
|
42
|
+
{% endif %}
|
|
43
|
+
{% endmacro %}
|