resumebuilder-tools 1.0.0__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.
@@ -0,0 +1,496 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import html
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+ from pypdf import PdfReader
9
+ from weasyprint import HTML
10
+
11
+ CSS_STYLES = """
12
+ @page {
13
+ size: A4;
14
+ margin: 7mm 8mm 6mm;
15
+ background-color: #ffffff;
16
+ }
17
+ body {
18
+ font-family: "Helvetica", "Arial", sans-serif;
19
+ color: #111;
20
+ line-height: 1.10;
21
+ margin: 0;
22
+ padding: 0;
23
+ font-size: 7.6pt;
24
+ }
25
+ .header {
26
+ text-align: center;
27
+ border-bottom: 1px solid #222;
28
+ padding-bottom: 5px;
29
+ margin-bottom: 6px;
30
+ }
31
+ h1 {
32
+ font-size: 17pt;
33
+ margin: 0;
34
+ color: #111;
35
+ text-transform: uppercase;
36
+ letter-spacing: 0.6px;
37
+ }
38
+ .job-title {
39
+ font-size: 10pt;
40
+ font-weight: bold;
41
+ color: #222;
42
+ margin: 2px 0;
43
+ }
44
+ .contact-info {
45
+ font-size: 8.2pt;
46
+ color: #333;
47
+ }
48
+ .contact-info a {
49
+ color: #111;
50
+ text-decoration: none;
51
+ }
52
+ h2 {
53
+ font-size: 9pt;
54
+ color: #111;
55
+ border-bottom: 1px solid #222;
56
+ padding-bottom: 1px;
57
+ margin: 6px 0 3px 0;
58
+ text-transform: uppercase;
59
+ letter-spacing: 0.4px;
60
+ }
61
+ p {
62
+ margin: 0 0 4px 0;
63
+ }
64
+ .experience-item, .project-item {
65
+ margin-bottom: 3px;
66
+ }
67
+ .item-header {
68
+ display: flex;
69
+ justify-content: space-between;
70
+ gap: 12px;
71
+ font-weight: bold;
72
+ margin-bottom: 1px;
73
+ }
74
+ .item-sub {
75
+ color: #333;
76
+ font-size: 8.2pt;
77
+ margin-bottom: 1px;
78
+ }
79
+ ul {
80
+ margin: 1px 0 3px 12px;
81
+ padding: 0;
82
+ }
83
+ li {
84
+ margin-bottom: 0;
85
+ }
86
+ .skills-line {
87
+ margin-bottom: 3px;
88
+ }
89
+ .skills-label,
90
+ .bold {
91
+ font-weight: bold;
92
+ }
93
+ .section-tight {
94
+ margin-bottom: 2px;
95
+ }
96
+ .project-points {
97
+ margin: 1px 0 2px 12px;
98
+ }
99
+ .subline {
100
+ display: flex;
101
+ justify-content: space-between;
102
+ gap: 12px;
103
+ }
104
+ .muted {
105
+ color: #333;
106
+ }
107
+ """
108
+
109
+
110
+ def esc(value: str | None) -> str:
111
+ return html.escape(value or "")
112
+
113
+
114
+ def build_contact_line(profile: dict) -> str:
115
+ parts: list[str] = []
116
+
117
+ if profile.get("phone"):
118
+ parts.append(esc(profile["phone"]))
119
+
120
+ if profile.get("email"):
121
+ email = esc(profile["email"])
122
+ parts.append(f'<a href="mailto:{email}">{email}</a>')
123
+
124
+ for link in profile.get("links", []):
125
+ label = esc(link.get("label", ""))
126
+ url = esc(link.get("url", "#"))
127
+ parts.append(f'<a href="{url}">{label}</a>')
128
+
129
+ if profile.get("location"):
130
+ parts.append(esc(profile["location"]))
131
+
132
+ return " | ".join(parts)
133
+
134
+
135
+ def render_skills(skills: list[dict]) -> str:
136
+ rows: list[str] = []
137
+ for skill in skills:
138
+ category = esc(skill.get("category", ""))
139
+ items = skill.get("items", [])
140
+ if isinstance(items, list):
141
+ item_text = ", ".join(esc(str(item)) for item in items)
142
+ else:
143
+ item_text = esc(str(items))
144
+ rows.append(
145
+ f"""
146
+ <div class=\"skills-line\"><span class=\"skills-label\">{category}:</span> {item_text}</div>
147
+ """
148
+ )
149
+
150
+ return "\n".join(rows)
151
+
152
+
153
+ def render_experience(experience: list[dict]) -> str:
154
+ blocks: list[str] = []
155
+ for item in experience:
156
+ role = esc(item.get("role", ""))
157
+ company = esc(item.get("company", ""))
158
+ location = esc(item.get("location", ""))
159
+ duration = esc(item.get("duration", ""))
160
+
161
+ company_line = " | ".join(part for part in [company, location] if part)
162
+
163
+ highlights = "\n".join(
164
+ f"<li>{esc(str(highlight))}</li>" for highlight in item.get("highlights", [])
165
+ )
166
+ highlight_list = f"<ul>{highlights}</ul>" if highlights else ""
167
+
168
+ blocks.append(
169
+ f"""
170
+ <div class=\"experience-item\">
171
+ <div class=\"item-header\"><span>{role}</span><span>{duration}</span></div>
172
+ <div class=\"item-sub\">{company_line}</div>
173
+ {highlight_list}
174
+ </div>
175
+ """
176
+ )
177
+
178
+ return "\n".join(blocks)
179
+
180
+
181
+ def render_projects(projects: list[dict]) -> str:
182
+ blocks: list[str] = []
183
+ for project in projects:
184
+ name = esc(project.get("name", ""))
185
+ description = esc(project.get("description", ""))
186
+ highlights = project.get("highlights", [])
187
+
188
+ project_points = ""
189
+ if isinstance(highlights, list) and highlights:
190
+ bullets = "\n".join(
191
+ f"<li>{esc(str(highlight))}</li>" for highlight in highlights
192
+ )
193
+ project_points = f'<ul class="project-points">{bullets}</ul>'
194
+
195
+ blocks.append(
196
+ f"""
197
+ <div class=\"project-item\">
198
+ <div class=\"item-header\">{name}</div>
199
+ {f'<div class="item-sub">{description}</div>' if description else ''}
200
+ {project_points}
201
+ </div>
202
+ """
203
+ )
204
+
205
+ return "\n".join(blocks)
206
+
207
+
208
+ def render_education(education: list[dict]) -> str:
209
+ items: list[str] = []
210
+ for item in education:
211
+ degree = esc(item.get("degree", ""))
212
+ institution = esc(item.get("institution", ""))
213
+ score = esc(item.get("score", ""))
214
+ date = esc(item.get("date", ""))
215
+
216
+ all_parts = [part for part in [degree, institution, date, score] if part]
217
+ line = " | ".join(f"<span class=\"bold\">{all_parts[0]}</span>" if i == 0 else esc(p)
218
+ for i, p in enumerate(all_parts))
219
+ items.append(f'<div class="section-tight">{line}</div>')
220
+
221
+ return "\n".join(items)
222
+
223
+
224
+ def render_bullets(items: list[str]) -> str:
225
+ return "\n".join(f"<li>{esc(str(item))}</li>" for item in items)
226
+
227
+
228
+ def extract_section_items(items: list[str], prefix: str) -> list[str]:
229
+ prefix_with_colon = f"{prefix}:"
230
+ extracted: list[str] = []
231
+ for item in items:
232
+ text = str(item)
233
+ if text.startswith(prefix_with_colon):
234
+ extracted.append(text[len(prefix_with_colon):].strip())
235
+ return extracted
236
+
237
+
238
+ def get_experience_years_label(years: str) -> str:
239
+ if not years:
240
+ return ""
241
+ return f" ({years} Years)"
242
+
243
+
244
+ def build_html(resume: dict) -> str:
245
+ profile = resume.get("profile", {})
246
+ experience = resume.get("experience", [])
247
+ summary = resume.get("summary", "")
248
+ experience_years = resume.get("experience_years", "")
249
+ cert_and_achievement_items = resume.get("certifications_and_achievements", [])
250
+ certifications = extract_section_items(cert_and_achievement_items, "Certifications")
251
+ achievements = extract_section_items(cert_and_achievement_items, "Achievements")
252
+ combined_certifications_and_achievements = []
253
+
254
+ if achievements:
255
+ combined_certifications_and_achievements.append(
256
+ f'Achievements: {"; ".join(achievements)}'
257
+ )
258
+ if certifications:
259
+ combined_certifications_and_achievements.append(
260
+ f'Certifications: {"; ".join(certifications)}'
261
+ )
262
+
263
+ sections: list[str] = []
264
+ if isinstance(summary, str) and summary.strip():
265
+ sections.append(f'<h2>Professional Summary</h2>\n <p>{esc(summary)}</p>')
266
+
267
+ skills = resume.get("skills", [])
268
+ if skills:
269
+ sections.append(f"<h2>Technical Skills</h2>\n {render_skills(skills)}")
270
+
271
+ if experience:
272
+ sections.append(
273
+ f"<h2>Professional Experience{get_experience_years_label(experience_years)}</h2>\n"
274
+ f" {render_experience(experience)}"
275
+ )
276
+
277
+ projects = resume.get("projects", [])
278
+ if projects:
279
+ sections.append(f"<h2>Projects</h2>\n {render_projects(projects)}")
280
+
281
+ education = resume.get("education", [])
282
+ if education:
283
+ sections.append(f"<h2>Education</h2>\n {render_education(education)}")
284
+
285
+ if combined_certifications_and_achievements:
286
+ sections.append(
287
+ "<h2>Certifications &amp; Achievements</h2>\n"
288
+ f" <ul>{render_bullets(combined_certifications_and_achievements)}</ul>"
289
+ )
290
+
291
+ sections_html = "\n\n ".join(sections)
292
+
293
+ return f"""
294
+ <!DOCTYPE html>
295
+ <html lang=\"en\">
296
+ <head>
297
+ <meta charset=\"UTF-8\">
298
+ <style>
299
+ {CSS_STYLES}
300
+ </style>
301
+ </head>
302
+ <body>
303
+
304
+ <div class=\"header\">
305
+ <h1>{esc(profile.get("name", ""))}</h1>
306
+ <div class=\"job-title\">{esc(profile.get("title", ""))}</div>
307
+ <div class=\"contact-info\">{build_contact_line(profile)}</div>
308
+ </div>
309
+
310
+ {sections_html}
311
+
312
+ </body>
313
+ </html>
314
+ """
315
+
316
+
317
+ def validate_pdf_output(
318
+ pdf_path: Path,
319
+ resume: dict,
320
+ html_content: str,
321
+ allow_multiple_pages: bool = False,
322
+ ) -> int:
323
+ lowered_html = html_content.lower()
324
+ if (
325
+ "<table" in lowered_html
326
+ or "column-count" in lowered_html
327
+ or "grid-template-columns" in lowered_html
328
+ ):
329
+ raise ValueError("Generated HTML is not a supported single-column layout.")
330
+
331
+ reader = PdfReader(str(pdf_path))
332
+ page_count = len(reader.pages)
333
+ if page_count != 1 and not allow_multiple_pages:
334
+ return page_count
335
+
336
+ text_fragments: list[tuple[int, str, float, float]] = []
337
+
338
+ def collect_text_position(page_number: int):
339
+ def collect(
340
+ text: str,
341
+ _current_matrix: list[float],
342
+ text_matrix: list[float],
343
+ _font_dictionary: dict | None,
344
+ _font_size: float,
345
+ ) -> None:
346
+ if text.strip():
347
+ text_fragments.append(
348
+ (page_number, text, float(text_matrix[4]), float(text_matrix[5]))
349
+ )
350
+
351
+ return collect
352
+
353
+ extracted_text = "\n".join(
354
+ page.extract_text(visitor_text=collect_text_position(page_number)) or ""
355
+ for page_number, page in enumerate(reader.pages)
356
+ )
357
+ if not extracted_text.strip():
358
+ raise ValueError("Generated PDF contains no extractable text.")
359
+
360
+ expected_headings: list[str] = []
361
+ if isinstance(resume.get("summary"), str) and resume["summary"].strip():
362
+ expected_headings.append("Professional Summary")
363
+ if resume.get("skills"):
364
+ expected_headings.append("Technical Skills")
365
+ if resume.get("experience"):
366
+ expected_headings.append("Professional Experience")
367
+ if resume.get("projects"):
368
+ expected_headings.append("Projects")
369
+ if resume.get("education"):
370
+ expected_headings.append("Education")
371
+ if resume.get("certifications_and_achievements"):
372
+ expected_headings.append("Certifications & Achievements")
373
+
374
+ normalized_text = " ".join(extracted_text.split()).casefold()
375
+ previous_index = -1
376
+ for heading in expected_headings:
377
+ current_index = normalized_text.find(heading.casefold(), previous_index + 1)
378
+ if current_index < 0:
379
+ raise ValueError(f"Generated PDF is missing expected heading: {heading}")
380
+ previous_index = current_index
381
+
382
+ if expected_headings:
383
+ first_heading = expected_headings[0].casefold()
384
+ body_start = next(
385
+ (
386
+ y
387
+ for page_number, text, _x, y in text_fragments
388
+ if page_number == 0 and first_heading in text.casefold()
389
+ ),
390
+ None,
391
+ )
392
+ if body_start is None:
393
+ raise ValueError("Could not locate the first section heading in the PDF text layout.")
394
+
395
+ page_widths = [float(page.mediabox.width) for page in reader.pages]
396
+ rows: dict[tuple[int, int], set[str]] = {}
397
+ for page_number, _text, x, y in text_fragments:
398
+ if page_number == 0 and y < body_start:
399
+ continue
400
+ page_width = page_widths[page_number]
401
+ row_key = (page_number, round(y / 2))
402
+ columns = rows.setdefault(row_key, set())
403
+ if x < page_width * 0.45:
404
+ columns.add("left")
405
+ elif x > page_width * 0.55:
406
+ columns.add("right")
407
+ split_rows = sum(1 for columns in rows.values() if {"left", "right"} <= columns)
408
+ if split_rows >= 4:
409
+ raise ValueError(
410
+ "Generated PDF text placement indicates a multi-column body layout."
411
+ )
412
+
413
+ return page_count
414
+
415
+
416
+ def load_resume_data(yaml_path: Path) -> dict:
417
+ with yaml_path.open("r", encoding="utf-8") as f:
418
+ data = yaml.safe_load(f) or {}
419
+
420
+ if not isinstance(data, dict):
421
+ raise ValueError("The YAML root must be a mapping/object.")
422
+
423
+ return data
424
+
425
+
426
+ def parse_args() -> argparse.Namespace:
427
+ parser = argparse.ArgumentParser(
428
+ description="Generate resume HTML and PDF from a YAML configuration file."
429
+ )
430
+ parser.add_argument(
431
+ "--config",
432
+ default="resume.yaml",
433
+ help="Path to resume YAML file (default: resume.yaml).",
434
+ )
435
+ parser.add_argument(
436
+ "--output-dir",
437
+ default="generated",
438
+ help="Directory where generated files are written (default: generated).",
439
+ )
440
+ parser.add_argument(
441
+ "--html-output",
442
+ default=None,
443
+ help="HTML output filename (default: derived from profile name in YAML).",
444
+ )
445
+ parser.add_argument(
446
+ "--pdf-output",
447
+ default=None,
448
+ help="PDF output filename (default: derived from profile name in YAML).",
449
+ )
450
+ parser.add_argument(
451
+ "--strict-one-page",
452
+ action="store_true",
453
+ help="Fail unless the generated PDF is one page with extractable text and expected headings.",
454
+ )
455
+ return parser.parse_args()
456
+
457
+
458
+ def main() -> None:
459
+ args = parse_args()
460
+
461
+ config_path = Path(args.config)
462
+ if not config_path.exists():
463
+ raise FileNotFoundError(f"YAML config not found: {config_path}")
464
+
465
+ resume = load_resume_data(config_path)
466
+ html_content = build_html(resume)
467
+
468
+ output_dir = Path(args.output_dir)
469
+ output_dir.mkdir(parents=True, exist_ok=True)
470
+
471
+ profile_name = resume.get("profile", {}).get("name", "Resume")
472
+ stem = profile_name.strip().replace(" ", "_")
473
+
474
+ html_path = Path(args.html_output) if args.html_output else Path(f"{stem}_Resume.html")
475
+ pdf_path = Path(args.pdf_output) if args.pdf_output else Path(f"{stem}_Resume.pdf")
476
+
477
+ if not html_path.is_absolute():
478
+ html_path = output_dir / html_path
479
+ if not pdf_path.is_absolute():
480
+ pdf_path = output_dir / pdf_path
481
+
482
+ html_path.parent.mkdir(parents=True, exist_ok=True)
483
+ pdf_path.parent.mkdir(parents=True, exist_ok=True)
484
+
485
+ html_path.write_text(html_content, encoding="utf-8")
486
+ HTML(str(html_path)).write_pdf(str(pdf_path))
487
+ if args.strict_one_page:
488
+ page_count = validate_pdf_output(pdf_path, resume, html_content)
489
+ if page_count != 1:
490
+ raise ValueError(
491
+ f"Strict one-page validation failed: generated PDF has {page_count} pages."
492
+ )
493
+
494
+
495
+ if __name__ == "__main__":
496
+ main()
@@ -0,0 +1 @@
1
+ """ResumeBuilder Python engine package."""