resumeforge 0.1.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.
- resumeforge/__init__.py +0 -0
- resumeforge/adapters/__init__.py +0 -0
- resumeforge/adapters/fpdf_adapter.py +63 -0
- resumeforge/adapters/heading_adapter.py +44 -0
- resumeforge/adapters/layout_adapter.py +33 -0
- resumeforge/cli.py +109 -0
- resumeforge/constants.py +13 -0
- resumeforge/engines/__init__.py +0 -0
- resumeforge/engines/fpdf_engine.py +152 -0
- resumeforge/mappers/__init__.py +0 -0
- resumeforge/mappers/heading_mapper.py +23 -0
- resumeforge/mappers/section_mapper.py +92 -0
- resumeforge/models.py +89 -0
- resumeforge/parser.py +39 -0
- resumeforge/renderer.py +80 -0
- resumeforge/transformer.py +158 -0
- resumeforge/validator.py +12 -0
- resumeforge-0.1.0.dist-info/METADATA +330 -0
- resumeforge-0.1.0.dist-info/RECORD +23 -0
- resumeforge-0.1.0.dist-info/WHEEL +5 -0
- resumeforge-0.1.0.dist-info/entry_points.txt +2 -0
- resumeforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- resumeforge-0.1.0.dist-info/top_level.txt +1 -0
resumeforge/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Adapter that converts RCSS declarations into fpdf2 rendering instructions."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from resumeforge.models import Declaration
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DisplayMode(Enum):
|
|
10
|
+
"""Determines which fpdf2 write method to use for section content."""
|
|
11
|
+
BLOCK = "block" # → multi_cell
|
|
12
|
+
INLINE = "inline" # → cell
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class SectionRenderStyle:
|
|
17
|
+
"""Adapted RCSS declarations for PDF rendering.
|
|
18
|
+
|
|
19
|
+
Separates declarations into state mutations applied before writing
|
|
20
|
+
content, parameters passed to the write call, and the display mode
|
|
21
|
+
that determines which write method to use.
|
|
22
|
+
"""
|
|
23
|
+
state_setters: list[callable] = field(default_factory=list)
|
|
24
|
+
write_params: dict = field(default_factory=dict)
|
|
25
|
+
display: DisplayMode = DisplayMode.BLOCK
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
|
29
|
+
"""Convert a hex color string like '#333333' to an (r, g, b) tuple."""
|
|
30
|
+
h = hex_color.lstrip("#")
|
|
31
|
+
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# State setters — mutate pdf state before writing content
|
|
35
|
+
STATE_HANDLERS = {
|
|
36
|
+
"font-size": lambda values: lambda pdf: pdf.set_font_size(float(values[0].replace("pt", ""))),
|
|
37
|
+
"color": lambda values: lambda pdf: pdf.set_text_color(*_hex_to_rgb(values[0])),
|
|
38
|
+
"background-color": lambda values: lambda pdf: pdf.set_fill_color(*_hex_to_rgb(values[0])),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Write params — passed to multi_cell/cell at write time
|
|
42
|
+
WRITE_PARAM_HANDLERS = {
|
|
43
|
+
"align": lambda values: ("align", values[0][0].upper()),
|
|
44
|
+
"line-height": lambda values: ("h", float(values[0])),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def adapt_declarations(declarations: list[Declaration]) -> SectionRenderStyle:
|
|
49
|
+
"""Convert RCSS declarations into a SectionRenderStyle for rendering.
|
|
50
|
+
|
|
51
|
+
Classifies each declaration as a state setter, a write parameter,
|
|
52
|
+
or a display mode directive.
|
|
53
|
+
"""
|
|
54
|
+
style = SectionRenderStyle()
|
|
55
|
+
for decl in declarations:
|
|
56
|
+
if decl.property == "display":
|
|
57
|
+
style.display = DisplayMode(decl.values[0])
|
|
58
|
+
elif decl.property in STATE_HANDLERS:
|
|
59
|
+
style.state_setters.append(STATE_HANDLERS[decl.property](decl.values))
|
|
60
|
+
elif decl.property in WRITE_PARAM_HANDLERS:
|
|
61
|
+
key, value = WRITE_PARAM_HANDLERS[decl.property](decl.values)
|
|
62
|
+
style.write_params[key] = value
|
|
63
|
+
return style
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from resumeforge.models import StyledHeading
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class HeadingConfig:
|
|
7
|
+
content: str
|
|
8
|
+
font_size: int
|
|
9
|
+
align: str
|
|
10
|
+
line_height: int
|
|
11
|
+
color: str | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def adapt_heading(heading: StyledHeading | None) -> HeadingConfig | None:
|
|
15
|
+
"""Convert a StyledHeading into a typed HeadingConfig for the render engine.
|
|
16
|
+
|
|
17
|
+
Applies ATS-friendly defaults (font_size=20, align=center, line_height=7)
|
|
18
|
+
and overrides with any user-specified declarations from the heading rule.
|
|
19
|
+
Returns None if heading is None (no heading content in CV).
|
|
20
|
+
|
|
21
|
+
TODO: Refactor to use _DECLARATION_ADAPTERS map and produce state_setters
|
|
22
|
+
(pdf-mutating callables) like fpdf_adapter does for SectionRenderStyle.
|
|
23
|
+
This would remove manual type conversion here and hex-to-rgb in the engine.
|
|
24
|
+
"""
|
|
25
|
+
if heading is None:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
props = {
|
|
29
|
+
"content": heading.content,
|
|
30
|
+
"font_size": 20,
|
|
31
|
+
"align": "center",
|
|
32
|
+
"line_height": 7,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if heading.rule:
|
|
36
|
+
for decl in heading.rule.declarations:
|
|
37
|
+
key = decl.property.replace("-", "_")
|
|
38
|
+
value = decl.values[0]
|
|
39
|
+
if key == "font_size":
|
|
40
|
+
value = int(value.rstrip("pt"))
|
|
41
|
+
elif key == "line_height":
|
|
42
|
+
value = int(value)
|
|
43
|
+
props[key] = value
|
|
44
|
+
return HeadingConfig(**props)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
from resumeforge.models import LayoutRule, Declaration
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class LayoutConfig:
|
|
7
|
+
"""renderer friendly layout configs from Layout Stylesheet (rcss)"""
|
|
8
|
+
mode: str
|
|
9
|
+
columns: int
|
|
10
|
+
column_widths: list[int]
|
|
11
|
+
column_gap: float
|
|
12
|
+
margins: tuple[float, float, float, float]
|
|
13
|
+
font_family: str | None = None
|
|
14
|
+
|
|
15
|
+
_DECLARATION_ADAPTERS: dict[str, Callable[[Declaration], Any]] = {
|
|
16
|
+
"mode": lambda d: d.values[0],
|
|
17
|
+
"columns": lambda d: int(d.values[0]),
|
|
18
|
+
"column-widths": lambda d: [int(v.rstrip("%")) for v in d.values],
|
|
19
|
+
"column-gap": lambda d: float(d.values[0].rstrip("mm")),
|
|
20
|
+
"margins": lambda d: tuple(float(v.rstrip("mm")) for v in d.values),
|
|
21
|
+
"font-family": lambda d: d.values[0].strip('"'),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
def adapt_layout(layout: LayoutRule) -> LayoutConfig:
|
|
25
|
+
props = {"column_widths": [50, 50]}
|
|
26
|
+
for decl in layout.declarations:
|
|
27
|
+
decl_adatper = _DECLARATION_ADAPTERS.get(decl.property)
|
|
28
|
+
if decl_adatper:
|
|
29
|
+
key = decl.property.replace("-", "_")
|
|
30
|
+
props[key] = decl_adatper(decl)
|
|
31
|
+
else:
|
|
32
|
+
raise ValueError(f"layout property '{decl.property}' is not valid")
|
|
33
|
+
return LayoutConfig(**props)
|
resumeforge/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""ResumeForge CLI entry point."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from resumeforge.parser import RcssParser
|
|
8
|
+
from resumeforge.transformer import transform
|
|
9
|
+
from resumeforge.mappers.section_mapper import SectionMapper
|
|
10
|
+
from resumeforge.mappers.heading_mapper import map as map_heading
|
|
11
|
+
from resumeforge.renderer import Renderer
|
|
12
|
+
from resumeforge.adapters.fpdf_adapter import adapt_declarations
|
|
13
|
+
from resumeforge.adapters.layout_adapter import adapt_layout
|
|
14
|
+
from resumeforge.engines.fpdf_engine import fpdf_engine
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="resumeforge",
|
|
20
|
+
description="Convert plain-text CVs into styled A4 PDFs using RCSS.",
|
|
21
|
+
)
|
|
22
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
23
|
+
|
|
24
|
+
# render cv txt to pdf command using rcss style file (formatting, style rules)
|
|
25
|
+
render = subparsers.add_parser("render", help="Render a resume to PDF")
|
|
26
|
+
render.add_argument("--input", required=True, help="Path to input .txt file")
|
|
27
|
+
render.add_argument("--style", required=True, help="Path to .rcss style file")
|
|
28
|
+
render.add_argument("--output", default="output.pdf", help="Output PDF path")
|
|
29
|
+
|
|
30
|
+
# validate your rcss style file
|
|
31
|
+
validate = subparsers.add_parser("validate", help="Validate RCSS syntax")
|
|
32
|
+
validate.add_argument("--style", required=True, help="Path to .rcss file to validate")
|
|
33
|
+
|
|
34
|
+
# check the cli version
|
|
35
|
+
subparsers.add_parser("version", help="Show version")
|
|
36
|
+
|
|
37
|
+
return parser
|
|
38
|
+
|
|
39
|
+
def cmd_render(args) -> int:
|
|
40
|
+
"""Render a resume for .txt + .rcss to PDF."""
|
|
41
|
+
|
|
42
|
+
# TODO - align the 'logging' pattern between all components
|
|
43
|
+
|
|
44
|
+
# 1. Parse the RCSS style file into a tree
|
|
45
|
+
print("[1/4] ✓ Parsing RCSS style file")
|
|
46
|
+
raw_rcss = Path(args.style).read_text()
|
|
47
|
+
result = RcssParser().parse(text=raw_rcss, options={"debug": True})
|
|
48
|
+
if not result.valid:
|
|
49
|
+
print(f"[1/4] ✗ Invalid RCSS: {result.message}")
|
|
50
|
+
return 1
|
|
51
|
+
|
|
52
|
+
# 2. Transform tree into domain models (layout, sections, declarations)
|
|
53
|
+
print("[2/4] ✓ Transforming parse tree to stylesheet")
|
|
54
|
+
stylesheet = transform(tree=result.tree, options={"debug": True})
|
|
55
|
+
|
|
56
|
+
# 3. Map raw text sections to their style sheet rules
|
|
57
|
+
print("[3/4] ✓ Mapping CV sections to style rules")
|
|
58
|
+
cv_text = Path(args.input).read_text()
|
|
59
|
+
styled_heading = map_heading(cv_text, stylesheet)
|
|
60
|
+
styled_sections = SectionMapper(options={"debug":True}).map(text=cv_text, stylesheet=stylesheet)
|
|
61
|
+
|
|
62
|
+
# 4. Render PDF from input .txt using transformed style models
|
|
63
|
+
print("[4/4] ✓ Rendering PDF")
|
|
64
|
+
Renderer(adapter=adapt_declarations, engine=fpdf_engine, layout_adapter=adapt_layout).render(
|
|
65
|
+
sections=styled_sections,
|
|
66
|
+
layout=stylesheet.layout,
|
|
67
|
+
output_path=args.output,
|
|
68
|
+
font_face=stylesheet.font_face,
|
|
69
|
+
heading=styled_heading
|
|
70
|
+
)
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
def cmd_validate(args) -> int:
|
|
74
|
+
"""Validate RCSS syntax and reports errors to stdout"""
|
|
75
|
+
text = Path(args.style).read_text()
|
|
76
|
+
validtion_result = RcssParser().validate(text)
|
|
77
|
+
|
|
78
|
+
if validtion_result.valid:
|
|
79
|
+
print("Valid RCSS")
|
|
80
|
+
return 0
|
|
81
|
+
else:
|
|
82
|
+
print(f"Invalid RCSS: {validtion_result.message}")
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
def cmd_version(args) -> int:
|
|
86
|
+
"""Print the current version."""
|
|
87
|
+
print("resumeforge 0.1.0")
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
def main(argv: list[str] | None = None) -> int:
|
|
91
|
+
parser = build_parser()
|
|
92
|
+
args = parser.parse_args(argv)
|
|
93
|
+
|
|
94
|
+
commands = {
|
|
95
|
+
"validate": cmd_validate,
|
|
96
|
+
"version": cmd_version,
|
|
97
|
+
"render": cmd_render
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
handler = commands.get(args.command)
|
|
101
|
+
if handler:
|
|
102
|
+
return handler(args)
|
|
103
|
+
|
|
104
|
+
parser.print_help()
|
|
105
|
+
return 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
sys.exit(main())
|
resumeforge/constants.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Default CV typography constants.
|
|
2
|
+
|
|
3
|
+
Used as fallbacks when no overrides are specified in @font-face or section rules.
|
|
4
|
+
Based on professional tech CV recommendations: clear, conservative, ATS-friendly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
"font-family": "Helvetica",
|
|
9
|
+
"heading-font-size": 12, # section headings: 11-14pt bold
|
|
10
|
+
"body-font-size": 11, # body content: 10.5-12pt regular
|
|
11
|
+
"title-font-size": 22, # name/title: 20-26pt bold
|
|
12
|
+
"line-height": 5, # ~1.1 spacing in mm
|
|
13
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""fpdf2 render engine — writes RenderSections to a PDF file."""
|
|
2
|
+
|
|
3
|
+
from fpdf import FPDF
|
|
4
|
+
|
|
5
|
+
from resumeforge.models import FontFaceRule
|
|
6
|
+
from resumeforge.renderer import RenderSection
|
|
7
|
+
from resumeforge.adapters.fpdf_adapter import DisplayMode
|
|
8
|
+
from resumeforge.adapters.layout_adapter import LayoutConfig
|
|
9
|
+
from resumeforge.adapters.heading_adapter import HeadingConfig
|
|
10
|
+
from resumeforge.constants import DEFAULTS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_font_face_value(font_face: FontFaceRule | None, prop: str, default=None):
|
|
14
|
+
"""Extract a value from font_face declarations."""
|
|
15
|
+
if font_face is None:
|
|
16
|
+
return default
|
|
17
|
+
for d in font_face.declarations:
|
|
18
|
+
if d.property == prop:
|
|
19
|
+
return d.values[0].strip('"')
|
|
20
|
+
return default
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _register_fonts(pdf: FPDF, font_face: FontFaceRule | None) -> str:
|
|
24
|
+
"""Register custom fonts from @font-face and return the font family name."""
|
|
25
|
+
if font_face is None:
|
|
26
|
+
return DEFAULTS["font-family"]
|
|
27
|
+
|
|
28
|
+
family = _get_font_face_value(font_face, "font-family", DEFAULTS["font-family"])
|
|
29
|
+
src = _get_font_face_value(font_face, "src")
|
|
30
|
+
src_bold = _get_font_face_value(font_face, "src-bold")
|
|
31
|
+
|
|
32
|
+
if src:
|
|
33
|
+
pdf.add_font(family, "", src)
|
|
34
|
+
if src_bold:
|
|
35
|
+
pdf.add_font(family, "B", src_bold)
|
|
36
|
+
|
|
37
|
+
return family
|
|
38
|
+
|
|
39
|
+
def _render_heading(pdf: FPDF, heading_config: HeadingConfig | None, font_family: str) -> None:
|
|
40
|
+
"""Render heading with Applicant name, contact info and role/title information."""
|
|
41
|
+
if heading_config is None:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
lines = heading_config.content.splitlines()
|
|
45
|
+
if not lines:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
font_size = int(heading_config.font_size) if isinstance(heading_config.font_size, str) else heading_config.font_size
|
|
49
|
+
line_height = int(heading_config.line_height) if isinstance(heading_config.line_height, str) else heading_config.line_height
|
|
50
|
+
align = heading_config.align[0].upper() # "center" -> "C", "left" -> "L", "right" -> "R"
|
|
51
|
+
|
|
52
|
+
# First line: name — bold, always black
|
|
53
|
+
pdf.set_text_color(0, 0, 0)
|
|
54
|
+
pdf.set_font(font_family, style="B", size=font_size)
|
|
55
|
+
pdf.multi_cell(w=0, h=line_height, text=lines[0], align=align, new_x="LMARGIN", new_y="NEXT")
|
|
56
|
+
|
|
57
|
+
# Remaining lines: contact/title — regular, scaled down, with color if set
|
|
58
|
+
if len(lines) > 1:
|
|
59
|
+
if heading_config.color:
|
|
60
|
+
# TODO: extract hex-to-rgb conversion to a shared adapter utility
|
|
61
|
+
r, g, b = int(heading_config.color[1:3], 16), int(heading_config.color[3:5], 16), int(heading_config.color[5:7], 16)
|
|
62
|
+
pdf.set_text_color(r, g, b)
|
|
63
|
+
contact_size = round(font_size * 0.55)
|
|
64
|
+
pdf.set_font(font_family, style="", size=contact_size)
|
|
65
|
+
for line in lines[1:]:
|
|
66
|
+
if line.strip():
|
|
67
|
+
pdf.multi_cell(w=0, h=line_height, text=line, align=align, new_x="LMARGIN", new_y="NEXT")
|
|
68
|
+
|
|
69
|
+
# Reset for subsequent sections
|
|
70
|
+
pdf.set_text_color(0, 0, 0)
|
|
71
|
+
pdf.ln(line_height)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _render_single(pdf: FPDF, sections: list[RenderSection], font_family: str) -> None:
|
|
75
|
+
"""Render sections sequentially in a single column."""
|
|
76
|
+
for section in sections:
|
|
77
|
+
_apply_and_write(pdf, section, w=0, font_family=font_family)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _render_grid(pdf: FPDF, sections: list[RenderSection], layout_config: LayoutConfig, font_family: str) -> None:
|
|
81
|
+
"""Render sections into a 2-column grid layout."""
|
|
82
|
+
gap = layout_config.column_gap
|
|
83
|
+
page_w = pdf.epw
|
|
84
|
+
usable_w = page_w - gap
|
|
85
|
+
col_widths = [usable_w * w / 100 for w in layout_config.column_widths]
|
|
86
|
+
col_x = [pdf.l_margin, pdf.l_margin + col_widths[0] + gap]
|
|
87
|
+
col_y = [pdf.get_y(), pdf.get_y()]
|
|
88
|
+
|
|
89
|
+
for section in sections:
|
|
90
|
+
col = (section.grid_column or 1) - 1
|
|
91
|
+
pdf.set_xy(col_x[col], col_y[col])
|
|
92
|
+
_apply_and_write(pdf, section, w=col_widths[col], x_after=col_x[col], font_family=font_family)
|
|
93
|
+
col_y[col] = pdf.get_y()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _apply_and_write(pdf: FPDF, section: RenderSection, w: float, font_family: str, x_after: float | None = None) -> None:
|
|
97
|
+
"""Apply state setters, write heading in bold, then write section content."""
|
|
98
|
+
new_x = "LEFT" if x_after is not None else "LMARGIN"
|
|
99
|
+
|
|
100
|
+
# Reset state to defaults before applying section overrides
|
|
101
|
+
pdf.set_text_color(0, 0, 0)
|
|
102
|
+
pdf.set_font_size(DEFAULTS["body-font-size"])
|
|
103
|
+
|
|
104
|
+
# Apply section-level style overrides (font-size, color, etc.)
|
|
105
|
+
for setter in section.style.state_setters:
|
|
106
|
+
setter(pdf)
|
|
107
|
+
|
|
108
|
+
# Resolve sizes: use section style if set, otherwise defaults
|
|
109
|
+
heading_size = pdf.font_size_pt or DEFAULTS["heading-font-size"]
|
|
110
|
+
body_size = pdf.font_size_pt or DEFAULTS["body-font-size"]
|
|
111
|
+
line_h = section.style.write_params.get("h", DEFAULTS["line-height"])
|
|
112
|
+
|
|
113
|
+
# Write section heading — bold, always black
|
|
114
|
+
pdf.set_text_color(0, 0, 0)
|
|
115
|
+
pdf.set_font(font_family, style="B", size=heading_size)
|
|
116
|
+
pdf.multi_cell(w=w, h=line_h, text=section.name, new_x=new_x, new_y="NEXT")
|
|
117
|
+
|
|
118
|
+
# Re-apply style setters (list of callables from SectionRenderStyle) to restore
|
|
119
|
+
# section color for body content — each setter is a lambda that mutates pdf state
|
|
120
|
+
for setter in section.style.state_setters:
|
|
121
|
+
setter(pdf)
|
|
122
|
+
|
|
123
|
+
# Write section content — regular
|
|
124
|
+
pdf.set_font(font_family, style="", size=body_size)
|
|
125
|
+
write_params = {**section.style.write_params, "h": line_h}
|
|
126
|
+
if section.style.display == DisplayMode.BLOCK:
|
|
127
|
+
pdf.multi_cell(w=w, text=section.content, new_x=new_x, new_y="NEXT", **write_params)
|
|
128
|
+
else:
|
|
129
|
+
pdf.cell(w=w, text=section.content, **write_params)
|
|
130
|
+
|
|
131
|
+
def fpdf_engine(
|
|
132
|
+
sections: list[RenderSection],
|
|
133
|
+
layout_config: LayoutConfig,
|
|
134
|
+
output_path: str,
|
|
135
|
+
font_face: FontFaceRule | None = None,
|
|
136
|
+
heading_config: HeadingConfig | None = None
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Render sections to a PDF file using fpdf2."""
|
|
139
|
+
pdf = FPDF()
|
|
140
|
+
pdf.add_page()
|
|
141
|
+
|
|
142
|
+
# Register custom font or use default
|
|
143
|
+
font_family = _register_fonts(pdf, font_face)
|
|
144
|
+
pdf.set_font(font_family, size=DEFAULTS["body-font-size"])
|
|
145
|
+
|
|
146
|
+
_render_heading(pdf, heading_config, font_family)
|
|
147
|
+
if layout_config.mode == "grid":
|
|
148
|
+
_render_grid(pdf, sections, layout_config, font_family)
|
|
149
|
+
else:
|
|
150
|
+
_render_single(pdf, sections, font_family)
|
|
151
|
+
|
|
152
|
+
pdf.output(output_path)
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from resumeforge.models import HeadingRule, Stylesheet, StyledHeading
|
|
2
|
+
|
|
3
|
+
def map(text: str, stylesheet: Stylesheet) -> StyledHeading:
|
|
4
|
+
"""Extract all text content until the first 'section' is found according to the user rcss selectors.
|
|
5
|
+
|
|
6
|
+
This is our heading
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
heading_rule: HeadingRule = stylesheet.heading
|
|
10
|
+
section_names = [s.name for s in stylesheet.sections]
|
|
11
|
+
lines = text.splitlines()
|
|
12
|
+
content_lines = []
|
|
13
|
+
for line in lines:
|
|
14
|
+
if line.strip() in section_names:
|
|
15
|
+
# we hit a section as defined in the users rcss, everything before is presumed to be the heading
|
|
16
|
+
break
|
|
17
|
+
content_lines.append(line)
|
|
18
|
+
|
|
19
|
+
content = "\n".join(content_lines).strip()
|
|
20
|
+
if not content:
|
|
21
|
+
raise ValueError("CV text must have heading content (name/contact) before the first section")
|
|
22
|
+
|
|
23
|
+
return StyledHeading(content=content, rule=heading_rule)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Maps raw CV text sections to their corresponding RCSS style rules."""
|
|
2
|
+
|
|
3
|
+
from resumeforge.models import Stylesheet, StyledSection, RawSection
|
|
4
|
+
from resumeforge.validator import run_validators
|
|
5
|
+
|
|
6
|
+
MAP_VALIDATORS = [
|
|
7
|
+
{
|
|
8
|
+
"check": lambda raw_sections, _: len(raw_sections) > 0,
|
|
9
|
+
"message": "No sections found in CV text matching the stylesheet. "
|
|
10
|
+
"Ensure headings match section[name=\"...\"] values exactly.",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"check": lambda raw_sections, rules_by_name: set(rules_by_name.keys()) == {s.name for s in raw_sections},
|
|
14
|
+
"message": "CV text is missing one or more sections defined in the stylesheet.",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"check": lambda raw_sections, rules_by_name: all(s.name in rules_by_name for s in raw_sections),
|
|
18
|
+
"message": "No matching stylesheet rule for one or more sections.",
|
|
19
|
+
},
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
class SectionMapper:
|
|
23
|
+
"""Splits a plain-text CV into sections and pairs each with its RCSS style rule.
|
|
24
|
+
|
|
25
|
+
A section is identified by a heading line that exactly matches a section name
|
|
26
|
+
defined in the Stylesheet (e.g. 'EXPERIENCE', 'EDUCATION', or 'John Joseph Strong').
|
|
27
|
+
Content is everything between that heading and the next heading or EOF.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, options: dict | None = None):
|
|
31
|
+
self._debug = options.get("debug") is True if options else False
|
|
32
|
+
|
|
33
|
+
def _log_section(self, section: tuple[str, str]):
|
|
34
|
+
"""Print section heading and body when debug mode is enabled."""
|
|
35
|
+
if self._debug:
|
|
36
|
+
print(f"heading: {section[0]}\nbody: {section[1]}")
|
|
37
|
+
|
|
38
|
+
def _split_sections(self, text: str, section_names: set[str]) -> list[RawSection]:
|
|
39
|
+
"""Split raw CV text into (name, content) pairs.
|
|
40
|
+
|
|
41
|
+
Walks line by line. A line whose stripped value matches a known section name
|
|
42
|
+
starts a new section. All subsequent lines (including blank lines) are captured
|
|
43
|
+
as content until the next heading or EOF.
|
|
44
|
+
|
|
45
|
+
Returns a list of (section_name, content) tuples in document order.
|
|
46
|
+
Text before the first matching heading is silently ignored.
|
|
47
|
+
"""
|
|
48
|
+
sections = []
|
|
49
|
+
current_name = None
|
|
50
|
+
current_lines = []
|
|
51
|
+
current_order = 0
|
|
52
|
+
|
|
53
|
+
for line in text.splitlines():
|
|
54
|
+
# Strip only for heading comparison — raw line preserved for content
|
|
55
|
+
stripped = line.strip()
|
|
56
|
+
if stripped in section_names:
|
|
57
|
+
# New heading found — save the previous section if one was open
|
|
58
|
+
if current_name:
|
|
59
|
+
sections.append(RawSection.fromText(current_name, current_lines, current_order))
|
|
60
|
+
current_order += 1
|
|
61
|
+
# Start collecting for this new section
|
|
62
|
+
current_name = stripped
|
|
63
|
+
current_lines = []
|
|
64
|
+
elif current_name:
|
|
65
|
+
# Inside a section — append raw line (preserves whitespace/blank lines)
|
|
66
|
+
current_lines.append(line)
|
|
67
|
+
# else: before any heading (preamble) — silently skipped
|
|
68
|
+
|
|
69
|
+
# EOF reached — flush the last open section
|
|
70
|
+
if current_name:
|
|
71
|
+
sections.append(RawSection.fromText(current_name, current_lines, current_order))
|
|
72
|
+
|
|
73
|
+
return sections
|
|
74
|
+
|
|
75
|
+
def _apply_rules(self, raw_sections: list[RawSection], rules_by_name: dict) -> list[StyledSection]:
|
|
76
|
+
"""Match each raw section to its SectionRule. Pure transformation — no validation."""
|
|
77
|
+
return [
|
|
78
|
+
StyledSection(name=s.name, content=s.content, rule=rules_by_name[s.name], order=s.order)
|
|
79
|
+
for s in raw_sections
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
def map(self, text: str, stylesheet: Stylesheet) -> list[StyledSection]:
|
|
83
|
+
"""Map raw CV text to a list of StyledSection objects.
|
|
84
|
+
|
|
85
|
+
Splits the text into sections by heading, validates, then pairs each
|
|
86
|
+
section with its matching SectionRule from the stylesheet.
|
|
87
|
+
"""
|
|
88
|
+
section_names = {s.name for s in stylesheet.sections}
|
|
89
|
+
rules_by_name = {rule.name: rule for rule in stylesheet.sections}
|
|
90
|
+
raw_sections = self._split_sections(text=text, section_names=section_names)
|
|
91
|
+
run_validators(MAP_VALIDATORS, raw_sections, rules_by_name)
|
|
92
|
+
return self._apply_rules(raw_sections=raw_sections, rules_by_name=rules_by_name)
|
resumeforge/models.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from lark import Tree
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class ValidationResult:
|
|
7
|
+
"""A validation result used by parser.py.
|
|
8
|
+
|
|
9
|
+
Encapsulates the status of lark prasing of a provided RCSS text with optional user friendly message to display
|
|
10
|
+
"""
|
|
11
|
+
valid: bool
|
|
12
|
+
message: str | None = None
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ParseResult:
|
|
16
|
+
"""Result of parsing RCSS text.
|
|
17
|
+
|
|
18
|
+
valid: whether the RCSS is syntactically correct
|
|
19
|
+
message: error description if valid is False
|
|
20
|
+
tree: the Lark parse tree if valid is True, None otherwise
|
|
21
|
+
"""
|
|
22
|
+
valid: bool
|
|
23
|
+
message: str | None = None
|
|
24
|
+
tree: Tree | None = None
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Declaration:
|
|
28
|
+
"""A single property: value pair like 'padding: 8mm'"""
|
|
29
|
+
property: str
|
|
30
|
+
values: list[str] # list because margins has 4 values: 20mm 18mm 20mm 18mm
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class LayoutRule:
|
|
34
|
+
"""The layout { ... } block — page-level settings"""
|
|
35
|
+
declarations: list[Declaration]
|
|
36
|
+
# Convenience: pull out mode, columns, margins etc. later via helper methods
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class HeadingRule:
|
|
40
|
+
"""The heading { } block - top level text content for Name, contact info and title"""
|
|
41
|
+
declarations: list[Declaration]
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class SectionRule:
|
|
45
|
+
"""A section[name="..."] { ... } block — per-section styles"""
|
|
46
|
+
name: str # the heading text, e.g. "HEADER", "WORK EXPERIENCE"
|
|
47
|
+
declarations: list[Declaration]
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class FontFaceRule:
|
|
51
|
+
"""A @font-face block"""
|
|
52
|
+
declarations: list[Declaration]
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Stylesheet:
|
|
56
|
+
"""The complete parsed .rcss file as domain objects"""
|
|
57
|
+
layout: LayoutRule # required — every .rcss must have a layout block
|
|
58
|
+
heading: HeadingRule | None # optional - falls-back to using ATS resonable defaults
|
|
59
|
+
sections: list[SectionRule] # required - every .rcss must have 1..N section blocks
|
|
60
|
+
font_face: FontFaceRule | None = None # optional - falls-back to default font
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class StyledHeading:
|
|
64
|
+
"""A heading with its rcss style and layout rules"""
|
|
65
|
+
content: str
|
|
66
|
+
rule: HeadingRule
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class StyledSection:
|
|
70
|
+
"""Encapsulated str content with its section rule from a Stylesheet"""
|
|
71
|
+
name: str
|
|
72
|
+
content: str
|
|
73
|
+
rule: SectionRule
|
|
74
|
+
order: int
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class RawSection:
|
|
78
|
+
"""A section extracted from raw CV text, before style rules are applied."""
|
|
79
|
+
name: str
|
|
80
|
+
content: str
|
|
81
|
+
order: int
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def fromText(name: str, content: list[str], order: int) -> 'RawSection':
|
|
85
|
+
return RawSection(
|
|
86
|
+
name=name,
|
|
87
|
+
content="\n".join(content),
|
|
88
|
+
order=order
|
|
89
|
+
)
|
resumeforge/parser.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Parser for RCSS DSL. Parse or Validate RCSS text"""
|
|
2
|
+
|
|
3
|
+
# Lark is the parsing library that reads grammar/rcss.lark and builds a parse tree
|
|
4
|
+
from lark import Lark
|
|
5
|
+
from lark.exceptions import UnexpectedToken, UnexpectedCharacters
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from resumeforge.models import ValidationResult, ParseResult
|
|
9
|
+
|
|
10
|
+
GRAMMAR_PATH = Path(__file__).parent / "grammar" / "rcss.lark"
|
|
11
|
+
|
|
12
|
+
class RcssParser:
|
|
13
|
+
def __init__(self):
|
|
14
|
+
"""Read the grammar and compile it"""
|
|
15
|
+
grammer_text = GRAMMAR_PATH.read_text()
|
|
16
|
+
self._parser = Lark(grammer_text, parser="lalr")
|
|
17
|
+
|
|
18
|
+
def validate(self, text: str):
|
|
19
|
+
"Validate a raw string against the rcss grammar."
|
|
20
|
+
try:
|
|
21
|
+
self._parser.parse(text)
|
|
22
|
+
return ValidationResult(valid = True)
|
|
23
|
+
except (UnexpectedToken, UnexpectedCharacters) as e:
|
|
24
|
+
return ValidationResult(
|
|
25
|
+
valid = False,
|
|
26
|
+
message = f"Line {e.line}, Col {e.column}: {e}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def parse(self, text: str, options: dict | None = None):
|
|
30
|
+
"""Parse RCSS text. Validates first, returns ParseResult with tree on success."""
|
|
31
|
+
result = self.validate(text)
|
|
32
|
+
if not result.valid:
|
|
33
|
+
return ParseResult(valid=False, message=result.message)
|
|
34
|
+
tree = self._parser.parse(text)
|
|
35
|
+
if options and options.get("debug") is True:
|
|
36
|
+
# Pretty print the AST when debug mode is enabled
|
|
37
|
+
print(tree.pretty())
|
|
38
|
+
|
|
39
|
+
return ParseResult(valid=True, tree=tree)
|
resumeforge/renderer.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Renders StyledSections into a paginated A4 PDF."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from resumeforge.models import FontFaceRule, LayoutRule, StyledSection, Declaration, StyledHeading
|
|
7
|
+
from resumeforge.adapters.fpdf_adapter import SectionRenderStyle
|
|
8
|
+
from resumeforge.adapters.layout_adapter import LayoutConfig
|
|
9
|
+
from resumeforge.adapters.heading_adapter import adapt_heading
|
|
10
|
+
|
|
11
|
+
# Type alias for any adapter function
|
|
12
|
+
StyleAdapter = Callable[[list[Declaration]], SectionRenderStyle]
|
|
13
|
+
LayoutAdapter = Callable[[LayoutRule], LayoutConfig]
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RenderSection:
|
|
17
|
+
"""A section prepared for the engine: content paired with its adapted style."""
|
|
18
|
+
name: str
|
|
19
|
+
content: str
|
|
20
|
+
style: SectionRenderStyle
|
|
21
|
+
order: int
|
|
22
|
+
grid_column: int | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Type alias for any render engine function
|
|
26
|
+
RenderEngine = Callable[[list["RenderSection"], LayoutConfig, str, FontFaceRule | None, StyledHeading | None], None]
|
|
27
|
+
|
|
28
|
+
class Renderer:
|
|
29
|
+
"""Takes styled sections and a layout rule, renders to PDF.
|
|
30
|
+
|
|
31
|
+
Adapter converts RCSS declarations into rendering instructions.
|
|
32
|
+
Engine writes the adapted sections to a specific output format.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, adapter: StyleAdapter, engine: RenderEngine, layout_adapter: LayoutAdapter, options: dict | None = None):
|
|
36
|
+
self._debug = options.get("debug") is True if options else False
|
|
37
|
+
self._adapter = adapter
|
|
38
|
+
self._engine = engine
|
|
39
|
+
self._layout_adapter = layout_adapter
|
|
40
|
+
|
|
41
|
+
def _log(self, step: str, detail: str):
|
|
42
|
+
if self._debug:
|
|
43
|
+
print(f"[renderer:{step}] {detail}")
|
|
44
|
+
|
|
45
|
+
def render(self,
|
|
46
|
+
sections: list[StyledSection],
|
|
47
|
+
layout: LayoutRule,
|
|
48
|
+
output_path: str,
|
|
49
|
+
font_face: FontFaceRule | None = None,
|
|
50
|
+
heading: StyledHeading | None = None
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Render styled sections to a PDF file at output_path."""
|
|
53
|
+
self._log("start", f"rendering {len(sections)} sections to {output_path}")
|
|
54
|
+
self._log("layout", f"{layout.declarations}")
|
|
55
|
+
|
|
56
|
+
layout_config = self._layout_adapter(layout)
|
|
57
|
+
self._log("layout_config", f"{layout_config}")
|
|
58
|
+
|
|
59
|
+
heading_config = adapt_heading(heading)
|
|
60
|
+
self._log("heading_config", f"{heading_config}")
|
|
61
|
+
|
|
62
|
+
render_sections = []
|
|
63
|
+
for section in sorted(sections, key=lambda s: s.order):
|
|
64
|
+
self._log("section", f"[{section.order}] {section.name}")
|
|
65
|
+
style = self._adapter(section.rule.declarations)
|
|
66
|
+
self._log("adapted", f"{style}")
|
|
67
|
+
grid_column = next(
|
|
68
|
+
(int(d.values[0]) for d in section.rule.declarations if d.property == "grid-column"),
|
|
69
|
+
None,
|
|
70
|
+
)
|
|
71
|
+
render_sections.append(RenderSection(
|
|
72
|
+
name=section.name,
|
|
73
|
+
content=section.content,
|
|
74
|
+
style=style,
|
|
75
|
+
order=section.order,
|
|
76
|
+
grid_column=grid_column,
|
|
77
|
+
))
|
|
78
|
+
|
|
79
|
+
self._engine(render_sections, layout_config, output_path, font_face=font_face, heading_config=heading_config)
|
|
80
|
+
self._log("done", output_path)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Transforms a Lark parse tree into domain models (Stylesheet)."""
|
|
2
|
+
|
|
3
|
+
from lark import Transformer, Token, Tree
|
|
4
|
+
from resumeforge.models import Declaration, FontFaceRule, LayoutRule, SectionRule, Stylesheet, HeadingRule
|
|
5
|
+
from resumeforge.validator import run_validators
|
|
6
|
+
|
|
7
|
+
def extract_column_widths_from_layout_declarations(layout: LayoutRule) -> Declaration | None:
|
|
8
|
+
return next((d for d in layout.declarations if d.property == 'column-widths'), None)
|
|
9
|
+
|
|
10
|
+
def _check_column_widths_format(layout: LayoutRule, _) -> bool:
|
|
11
|
+
"""Ensure column-widths values are integer percentages."""
|
|
12
|
+
decl = extract_column_widths_from_layout_declarations(layout)
|
|
13
|
+
if decl is None:
|
|
14
|
+
return True
|
|
15
|
+
for v in decl.values:
|
|
16
|
+
if not v.endswith("%"):
|
|
17
|
+
return False
|
|
18
|
+
number = v[:-1]
|
|
19
|
+
if not number.isdigit():
|
|
20
|
+
return False
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
def _check_column_widths_sum(layout: LayoutRule, _) -> bool:
|
|
24
|
+
"""Ensure if column_widths is set the values sum to 100%"""
|
|
25
|
+
decl = extract_column_widths_from_layout_declarations(layout)
|
|
26
|
+
if decl is None:
|
|
27
|
+
return True
|
|
28
|
+
values = [int(v.rstrip("%")) for v in decl.values]
|
|
29
|
+
return sum(values) == 100
|
|
30
|
+
|
|
31
|
+
STYLESHEET_RULE_VALIDATORS = [
|
|
32
|
+
{
|
|
33
|
+
"check": lambda layout, _: layout is not None,
|
|
34
|
+
"message": "RCSS must contain a layout { ... } rule",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"check": _check_column_widths_format,
|
|
38
|
+
"message": "column-widths values must be whole numbers with % (e.g. \"35% 65%\")",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"check": _check_column_widths_sum,
|
|
42
|
+
"message": "column-widths must sum to 100%",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"check": lambda _, sections: len(sections) > 0,
|
|
46
|
+
"message": "RCSS must contain at least one section[name=\"...\"] rule",
|
|
47
|
+
},
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
class RcssTransformer(Transformer):
|
|
51
|
+
"""Walks the parse tree bottom-up, converting nodes into domain models."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, options: dict | None = None):
|
|
54
|
+
super().__init__()
|
|
55
|
+
self._debug = options.get("debug") is True if options else False
|
|
56
|
+
|
|
57
|
+
def _log(self, rule_name: str, items):
|
|
58
|
+
if self._debug:
|
|
59
|
+
print(f"[{rule_name}] {items}")
|
|
60
|
+
|
|
61
|
+
def value(self, items) -> list[str]:
|
|
62
|
+
"""Extract values from TOKEN array.
|
|
63
|
+
|
|
64
|
+
e.g. Token('VALUE', '8mm') → str(token) → "8mm"
|
|
65
|
+
"""
|
|
66
|
+
self._log("value", items)
|
|
67
|
+
return [str(token) for token in items]
|
|
68
|
+
|
|
69
|
+
def declaration(self, items) -> Declaration:
|
|
70
|
+
"""Extract property and values[] from TOKEN.
|
|
71
|
+
|
|
72
|
+
e.g. Token('PROPERTY', 'padding'), ['8mm'] -> Declaration('padding', ['8mm'])
|
|
73
|
+
"""
|
|
74
|
+
self._log("declaration", items)
|
|
75
|
+
return Declaration(property=str(items[0]), values=items[1])
|
|
76
|
+
|
|
77
|
+
def layout_selector(self, items) -> str:
|
|
78
|
+
"""layout is a dsl selector it doesn't have any tokens"""
|
|
79
|
+
self._log("layout_selector", items)
|
|
80
|
+
return "layout"
|
|
81
|
+
|
|
82
|
+
def heading_selector(self, items) -> str:
|
|
83
|
+
"""heading is a dsl selector representing beginning of a well-formed resume.
|
|
84
|
+
|
|
85
|
+
e.g.
|
|
86
|
+
Name
|
|
87
|
+
[contact info ..]
|
|
88
|
+
"""
|
|
89
|
+
self._log("heading_selector", items)
|
|
90
|
+
return "heading"
|
|
91
|
+
|
|
92
|
+
def section_selector(self, items) -> str:
|
|
93
|
+
"""Extract the section name, stripping quotes from the STRING token.
|
|
94
|
+
|
|
95
|
+
e.g. Token('STRING', '"HEADER"') → "HEADER"
|
|
96
|
+
"""
|
|
97
|
+
self._log("section_selector", items)
|
|
98
|
+
return str(items[0]).strip('"')
|
|
99
|
+
|
|
100
|
+
def fontface_selector(self, items) -> str:
|
|
101
|
+
"""@font-face is a css-compatible dsl selector. it has no tokens.
|
|
102
|
+
|
|
103
|
+
"""
|
|
104
|
+
self._log("fontface_selector", items)
|
|
105
|
+
return "font-face"
|
|
106
|
+
|
|
107
|
+
def rule(self, items) -> LayoutRule | SectionRule:
|
|
108
|
+
"""Map a selector + declarations into a LayoutRule or SectionRule.
|
|
109
|
+
|
|
110
|
+
items[0] is the selector return value ("layout" or a section name).
|
|
111
|
+
items[1:] are Declaration objects from each declaration in the block.
|
|
112
|
+
"""
|
|
113
|
+
self._log("rule", items)
|
|
114
|
+
selector = items[0]
|
|
115
|
+
declarations = items[1:]
|
|
116
|
+
if selector == "layout":
|
|
117
|
+
return LayoutRule(declarations=declarations)
|
|
118
|
+
if selector == "font-face":
|
|
119
|
+
return FontFaceRule(declarations=declarations)
|
|
120
|
+
if selector == "heading":
|
|
121
|
+
return HeadingRule(declarations=declarations)
|
|
122
|
+
return SectionRule(name=selector, declarations=declarations)
|
|
123
|
+
|
|
124
|
+
def start(self, items) -> Stylesheet:
|
|
125
|
+
"""Map the rules to layout and section[] Stylesheet.
|
|
126
|
+
|
|
127
|
+
e.g.
|
|
128
|
+
[
|
|
129
|
+
LayoutRule(
|
|
130
|
+
declarations=Declaration(property='mode', values=['grid'])
|
|
131
|
+
),
|
|
132
|
+
SectionRule(name='"HEADER"',
|
|
133
|
+
declarations=Declaration(property='grid-column', values=['1'])
|
|
134
|
+
)
|
|
135
|
+
] -> StyleSheet(LayoutRule, SectionRule[])
|
|
136
|
+
"""
|
|
137
|
+
self._log("start", items)
|
|
138
|
+
layout = None
|
|
139
|
+
font_face = None
|
|
140
|
+
heading = None
|
|
141
|
+
sections = []
|
|
142
|
+
for rule in items:
|
|
143
|
+
if isinstance(rule, LayoutRule):
|
|
144
|
+
layout = rule
|
|
145
|
+
elif isinstance(rule, FontFaceRule):
|
|
146
|
+
font_face = rule
|
|
147
|
+
elif isinstance(rule, HeadingRule):
|
|
148
|
+
heading = rule
|
|
149
|
+
else:
|
|
150
|
+
sections.append(rule)
|
|
151
|
+
|
|
152
|
+
run_validators(STYLESHEET_RULE_VALIDATORS, layout, sections)
|
|
153
|
+
return Stylesheet(layout=layout, heading=heading, sections=sections, font_face=font_face)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def transform(tree: Tree, options: dict | None = None) -> Stylesheet:
|
|
157
|
+
"""Entry point: transform a Lark tree into a Stylesheet domain model."""
|
|
158
|
+
return RcssTransformer(options).transform(tree)
|
resumeforge/validator.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Shared validation runner for declarative rule lists."""
|
|
2
|
+
|
|
3
|
+
def run_validators(validators: list[dict], *args):
|
|
4
|
+
"""Run a list of validation rules. Raises ValueError on first failure.
|
|
5
|
+
|
|
6
|
+
Each validator is a dict with:
|
|
7
|
+
- check: callable that receives *args and returns True/False
|
|
8
|
+
- message: error string to surface if check fails
|
|
9
|
+
"""
|
|
10
|
+
for rule in validators:
|
|
11
|
+
if not rule["check"](*args):
|
|
12
|
+
raise ValueError(rule["message"])
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: resumeforge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert plain-text CVs into styled A4 PDFs using RCSS
|
|
5
|
+
Author: John Strong
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://resume-forge-cli.web.app/
|
|
8
|
+
Project-URL: Repository, https://github.com/JohnStrong/ResumeForge
|
|
9
|
+
Project-URL: Issues, https://github.com/JohnStrong/ResumeForge/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: lark
|
|
17
|
+
Requires-Dist: fpdf2
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest; extra == "test"
|
|
20
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
21
|
+
Requires-Dist: pypdf; extra == "test"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# ResumeForge — README
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+

|
|
28
|
+

|
|
29
|
+

|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
> 🌐 **Website:** https://resume-forge-cli.web.app/
|
|
33
|
+
|
|
34
|
+
## Table of Contents
|
|
35
|
+
- [About](#about)
|
|
36
|
+
- [Setup](#setup)
|
|
37
|
+
- [Usage](#usage)
|
|
38
|
+
- [Troubleshooting](#troubleshooting)
|
|
39
|
+
- [Testing](#testing)
|
|
40
|
+
- [RCSS DSL](#rcss-dsl)
|
|
41
|
+
- [Section identification](#section-identification)
|
|
42
|
+
- [.rcss basics (MVP)](#rcss-basics-mvp)
|
|
43
|
+
- [Example .rcss snippets](#example-rcss-snippets)
|
|
44
|
+
- [Contributing](#contributing)
|
|
45
|
+
- [Examples](#examples)
|
|
46
|
+
|
|
47
|
+
## About
|
|
48
|
+
ResumeForge converts a plain UTF-8 text CV into a styled multi-page A4 PDF using a small CSS-like DSL (.rcss). Supports two layout modes: standard (single-column) and grid (2-column). MVP excludes font-face loading and decorative assets.
|
|
49
|
+
|
|
50
|
+
## Setup
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python3 -m venv .venv
|
|
54
|
+
source .venv/bin/activate
|
|
55
|
+
pip install -e .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Render a plain-text CV to styled PDF
|
|
62
|
+
resumeforge render --input examples/resume.txt --style examples/valid.rcss --output resume.pdf
|
|
63
|
+
|
|
64
|
+
# Validate an RCSS style file for syntax errors
|
|
65
|
+
resumeforge validate --style examples/valid.rcss
|
|
66
|
+
|
|
67
|
+
# Print CLI version
|
|
68
|
+
resumeforge version
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Troubleshooting
|
|
72
|
+
|
|
73
|
+
**"Unexpected token ... Expected one of: HEADING, LAYOUT, @font-face, SECTION"**
|
|
74
|
+
Your `.rcss` file has an invalid selector. Only `layout { ... }`, `heading { ... }`, `@font-face { ... }`, and `section[name="..."] { ... }` are valid. Check for typos in the selector keyword.
|
|
75
|
+
|
|
76
|
+
**"Unexpected token ... Expected one of: SEMICOLON"**
|
|
77
|
+
A property declaration is missing its trailing semicolon. Every declaration must end with `;`.
|
|
78
|
+
|
|
79
|
+
**"RCSS must contain a layout { ... } rule"**
|
|
80
|
+
The transformer could not find a `layout` block in your `.rcss` file. Every stylesheet requires one.
|
|
81
|
+
|
|
82
|
+
**"RCSS must contain at least one section[name=...] rule"**
|
|
83
|
+
Your `.rcss` defines a layout but no section rules. Add at least one `section[name="..."] { ... }` block.
|
|
84
|
+
|
|
85
|
+
**"No sections found in CV text matching the stylesheet"**
|
|
86
|
+
The headings in your `.txt` file don't match any `section[name="..."]` values in the stylesheet. Headings must match exactly (case-sensitive, full line).
|
|
87
|
+
|
|
88
|
+
**"CV text is missing one or more sections defined in the stylesheet"**
|
|
89
|
+
Your `.txt` file is missing a heading that the stylesheet expects. Ensure every `section[name="..."]` in the `.rcss` has a corresponding heading line in the CV text.
|
|
90
|
+
|
|
91
|
+
**"No raw sections to apply rules to"**
|
|
92
|
+
The section mapper received no parsed sections to style. This typically means your CV text was empty or contained no lines matching any stylesheet section names.
|
|
93
|
+
|
|
94
|
+
**"No matching stylesheet rule for one or more sections"**
|
|
95
|
+
A section was parsed from the CV text but has no corresponding `section[name="..."]` rule in the stylesheet. Ensure every heading in your `.txt` file has a matching rule in the `.rcss`.
|
|
96
|
+
|
|
97
|
+
**"column-widths must sum to 100%"**
|
|
98
|
+
The percentage values in `column-widths` do not add up to 100. For example, `column-widths: 30% 60%;` totals 90%. Adjust so they equal 100%.
|
|
99
|
+
|
|
100
|
+
**"column-widths values must be whole numbers with %"**
|
|
101
|
+
Each value in `column-widths` must be an integer followed by `%`. Decimal values like `33.3%` and bare numbers like `35` are not allowed.
|
|
102
|
+
|
|
103
|
+
**"layout property '...' is not valid"**
|
|
104
|
+
The layout adapter encountered an unrecognised property in `layout { ... }`. Check for typos. Valid properties: `mode`, `columns`, `column-widths`, `column-gap`, `margins`, `font-family`.
|
|
105
|
+
|
|
106
|
+
**"CV text must have heading content (name/contact) before the first section"**
|
|
107
|
+
Your `.txt` file begins immediately with a section heading (e.g. `Skills` or `Experience`) with no name or contact information above it. Every CV must have at least one line of text before the first section — typically your full name, job title, and contact details (email, phone, LinkedIn). This heading block is rendered at the top of the PDF before any sections.
|
|
108
|
+
|
|
109
|
+
## Testing
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pip install pytest
|
|
113
|
+
pytest
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## RCSS DSL
|
|
117
|
+
|
|
118
|
+
> Grammar definition: [`src/resumeforge/grammar/rcss.lark`](src/resumeforge/grammar/rcss.lark)
|
|
119
|
+
|
|
120
|
+
### Section identification
|
|
121
|
+
- A section begins at a heading line that matches the pattern ^{HEADING} (a full-line header like: LINKS, WORK EXPERIENCE, EDUCATION).
|
|
122
|
+
- A section contains all text from that heading line up to the next heading line or EOF.
|
|
123
|
+
- Section selectors in .rcss match the heading text exactly (e.g., section[name="WORK EXPERIENCE"]).
|
|
124
|
+
|
|
125
|
+
### .rcss basics (MVP)
|
|
126
|
+
- File extension: .rcss
|
|
127
|
+
- Grid mode supports exactly 2 columns. grid-column must be 1 or 2 for each section in grid mode.
|
|
128
|
+
|
|
129
|
+
#### Layout properties (in `layout { ... }`)
|
|
130
|
+
| Property | Values | Description |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `mode` | `single`, `grid` | Page layout mode |
|
|
133
|
+
| `columns` | `2` | Number of columns (grid mode) |
|
|
134
|
+
| `column-widths` | e.g. `35% 65%` | Width of each column as percentages (grid mode, must sum to 100%) |
|
|
135
|
+
| `column-gap` | e.g. `6mm` | Gap between columns |
|
|
136
|
+
| `margins` | e.g. `20mm 18mm 20mm 18mm` | Page margins (top right bottom left) |
|
|
137
|
+
| `font-family` | e.g. `"Helvetica"` | Default font (overridden by @font-face) |
|
|
138
|
+
|
|
139
|
+
#### Font face properties (in `@font-face { ... }`) — optional
|
|
140
|
+
| Property | Values | Description |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `font-family` | e.g. `"Carlito"` | Font family name to register |
|
|
143
|
+
| `src` | e.g. `"fonts/Carlito-Regular.ttf"` | Path to regular weight TTF |
|
|
144
|
+
| `src-bold` | e.g. `"fonts/Carlito-Bold.ttf"` | Path to bold weight TTF |
|
|
145
|
+
|
|
146
|
+
#### Heading properties (in `heading { ... }`) — optional
|
|
147
|
+
The `heading` block styles the resume header (name, title, contact info) that appears before the first section. If omitted, ATS-friendly defaults are applied automatically.
|
|
148
|
+
|
|
149
|
+
| Property | Values | Default | Description |
|
|
150
|
+
|---|---|---|---|
|
|
151
|
+
| `font-size` | e.g. `20pt` | `20pt` | Name/first line font size. Subsequent lines (contact info, title) are scaled down proportionally |
|
|
152
|
+
| `align` | `left`, `center`, `right` | `center` | Text alignment |
|
|
153
|
+
| `line-height` | e.g. `7` | `7` | Line height in mm |
|
|
154
|
+
| `color` | e.g. `#333333` | black | Text color (hex) |
|
|
155
|
+
|
|
156
|
+
#### Section properties (in `section[name="..."] { ... }`)
|
|
157
|
+
|
|
158
|
+
**Style properties** (PDF render mode — applied as PDF state before writing):
|
|
159
|
+
| Property | Values | Default | Description |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| `font-size` | e.g. `12pt` | `11pt` | Text size |
|
|
162
|
+
| `color` | e.g. `#333333` | black | Text color (hex) |
|
|
163
|
+
| `background-color` | e.g. `#f0f0f0` | none | Section fill color (hex) |
|
|
164
|
+
|
|
165
|
+
**Write properties** (PDF render mode — control how content is rendered):
|
|
166
|
+
| Property | Values | Default | Description |
|
|
167
|
+
|---|---|---|---|
|
|
168
|
+
| `align` | `left`, `center`, `right` | `left` | Text alignment |
|
|
169
|
+
| `line-height` | e.g. `7` | `5` | Line height in mm |
|
|
170
|
+
| `display` | `block`, `inline` | `block` | Block wraps text (multi-line), inline flows horizontally |
|
|
171
|
+
|
|
172
|
+
**Layout positioning** (grid mode only):
|
|
173
|
+
| Property | Values | Default | Description |
|
|
174
|
+
|---|---|---|---|
|
|
175
|
+
| `grid-column` | `1`, `2` | — | Which column to place the section in |
|
|
176
|
+
| `padding` | e.g. `8mm` | `0` | Inner spacing |
|
|
177
|
+
| `width` | e.g. `1fr` | `1fr` | Proportional column width |
|
|
178
|
+
|
|
179
|
+
### Example .rcss snippets
|
|
180
|
+
Single-column (resume-single.rcss)
|
|
181
|
+
```css
|
|
182
|
+
layout { mode: single; margins: 20mm 18mm 20mm 18mm; }
|
|
183
|
+
|
|
184
|
+
section[name="HEADER"] {
|
|
185
|
+
padding: 8mm;
|
|
186
|
+
align: center;
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Two-column grid (resume-grid.rcss)
|
|
191
|
+
```css
|
|
192
|
+
layout { mode: grid; columns: 2; column-widths: 35% 65%; column-gap: 6mm; margins: 20mm 18mm 20mm 18mm; }
|
|
193
|
+
|
|
194
|
+
/* Place by heading text and explicit column (1 or 2) */
|
|
195
|
+
section[name="SIDEBAR"] {
|
|
196
|
+
grid-column: 1;
|
|
197
|
+
padding: 6mm;
|
|
198
|
+
width: 1fr;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
section[name="MAIN"] {
|
|
202
|
+
grid-column: 2;
|
|
203
|
+
padding: 6mm;
|
|
204
|
+
width: 1fr;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
section[name="HEADER"] {
|
|
208
|
+
grid-column: 1;
|
|
209
|
+
padding: 8mm;
|
|
210
|
+
align: center;
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Contributing
|
|
215
|
+
|
|
216
|
+
1. Create a feature branch from `main`: `git checkout -b feature/your-feature`
|
|
217
|
+
2. Make changes, commit using [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`)
|
|
218
|
+
3. Ensure all tests pass: `pytest`
|
|
219
|
+
4. Open a pull request for code review before merging
|
|
220
|
+
5. Merge to `main` with `--no-ff` to preserve branch history
|
|
221
|
+
|
|
222
|
+
## Examples
|
|
223
|
+
|
|
224
|
+
### Step 1: Write your CV as plain text (`examples/resume.txt`)
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
Lorem Ipsum
|
|
228
|
+
Senior Software Engineer
|
|
229
|
+
lorem.ipsum@fakeemail.xyz | +44 0000 000000
|
|
230
|
+
|
|
231
|
+
Links
|
|
232
|
+
github.com/loremipsum
|
|
233
|
+
linkedin.com/in/loremipsum
|
|
234
|
+
loremipsum.dev
|
|
235
|
+
|
|
236
|
+
Skills
|
|
237
|
+
- Python,
|
|
238
|
+
- TypeScript
|
|
239
|
+
- Go
|
|
240
|
+
- Rust
|
|
241
|
+
- AWS (Lambda, DynamoDB, ECS, CDK)
|
|
242
|
+
- Terraform
|
|
243
|
+
- PostgreSQL,
|
|
244
|
+
- Redis,
|
|
245
|
+
- Kafka
|
|
246
|
+
- System Design
|
|
247
|
+
- CI/CD
|
|
248
|
+
- Kubernetes
|
|
249
|
+
- Observability
|
|
250
|
+
|
|
251
|
+
Work Experience
|
|
252
|
+
Senior Software Engineer - Acme Widget Corp
|
|
253
|
+
Jan 2021 - Present
|
|
254
|
+
- Architected event-driven microservices processing 2M+ events/day
|
|
255
|
+
- Led monolith-to-ECS migration reducing deploy times by 70%
|
|
256
|
+
- Designed real-time analytics pipeline using Kafka and Flink
|
|
257
|
+
- Mentored 4 junior engineers through pairing and code review
|
|
258
|
+
|
|
259
|
+
Software Engineer - Placeholder Technologies Inc
|
|
260
|
+
Mar 2018 - Dec 2020
|
|
261
|
+
- Built REST and gRPC APIs serving 500K daily active users
|
|
262
|
+
- Implemented canary deployments reducing rollback incidents by 85%
|
|
263
|
+
- Developed internal CLI tooling adopted by 3 engineering teams
|
|
264
|
+
|
|
265
|
+
Software Engineer - Foobar Systems Ltd
|
|
266
|
+
Sep 2015 - Feb 2018
|
|
267
|
+
- Developed customer-facing dashboard using React and TypeScript
|
|
268
|
+
- Designed multi-tenant SaaS schema in PostgreSQL
|
|
269
|
+
- Reduced API response times by 40% through caching
|
|
270
|
+
|
|
271
|
+
Education
|
|
272
|
+
MSc Computer Science - University of Nowhere, 2015
|
|
273
|
+
BSc Mathematics - University of Somewhere, 2013
|
|
274
|
+
|
|
275
|
+
References
|
|
276
|
+
Dolor Sit Amet
|
|
277
|
+
Engineering Director, Acme Widget Corp
|
|
278
|
+
dolor.sit@fakecorp.xyz
|
|
279
|
+
|
|
280
|
+
Consectetur Adipiscing
|
|
281
|
+
CTO, Placeholder Technologies Inc
|
|
282
|
+
consectetur@faketech.xyz
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Step 2: Style it with RCSS (`examples/valid.rcss`)
|
|
286
|
+
|
|
287
|
+
```css
|
|
288
|
+
@font-face { font-family: "Carlito"; src: "examples/fonts/Carlito-Regular.ttf"; src-bold: "examples/fonts/Carlito-Bold.ttf"; }
|
|
289
|
+
|
|
290
|
+
layout { mode: grid; columns: 2; column-widths: 30% 70%; column-gap: 6mm; margins: 20mm 18mm 20mm 18mm; font-family: "Carlito"; }
|
|
291
|
+
|
|
292
|
+
heading { font-size: 20pt; align: center; line-height: 7; color: #555555; }
|
|
293
|
+
|
|
294
|
+
section[name="Links"] {
|
|
295
|
+
color: #336699;
|
|
296
|
+
line-height: 5;
|
|
297
|
+
grid-column: 1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
section[name="Skills"] {
|
|
301
|
+
line-height: 5;
|
|
302
|
+
grid-column: 1;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
section[name="Work Experience"] {
|
|
306
|
+
line-height: 5;
|
|
307
|
+
grid-column: 2;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
section[name="Education"] {
|
|
311
|
+
line-height: 5;
|
|
312
|
+
grid-column: 2;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
section[name="References"] {
|
|
316
|
+
color: #555555;
|
|
317
|
+
line-height: 5;
|
|
318
|
+
grid-column: 2;
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Step 3: Render to PDF
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
resumeforge render --input examples/resume.txt --style examples/valid.rcss --output examples/resume.pdf
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Result
|
|
329
|
+
|
|
330
|
+

|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
resumeforge/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
resumeforge/cli.py,sha256=lCstEc2wSt5QgrbctNuWVMPwUnBdauqsw5WyWDKINzs,3855
|
|
3
|
+
resumeforge/constants.py,sha256=RZnLATxGbTy0E4oWvQ82HZ2K3iodmEZVW1hNgfNzt-A,507
|
|
4
|
+
resumeforge/models.py,sha256=Xw4DVUQXbBUukEok_g_050IQKNoDAsHElrVqr5x14lM,2596
|
|
5
|
+
resumeforge/parser.py,sha256=uj3pnvGBhFWXMgSBV_vLD3OYbJh_XHzJHiKoc4X6-sM,1510
|
|
6
|
+
resumeforge/renderer.py,sha256=ybybsbrQhPap0tN_p21TzBxY_rAzQ0_3cYBrdUN92QE,3123
|
|
7
|
+
resumeforge/transformer.py,sha256=qxSGqjOgkBV29DfPAUOhMKps8F_dqkuDuMH784RecQ8,5676
|
|
8
|
+
resumeforge/validator.py,sha256=QSgY4yfey-0p8ylt87mv-wJkHL9cqT4x9lZVAZ5m3Vw,464
|
|
9
|
+
resumeforge/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
resumeforge/adapters/fpdf_adapter.py,sha256=Hdx9E-UB0zLi3HT3BURR9J9HNnfYbYTUgc22-tEhnug,2353
|
|
11
|
+
resumeforge/adapters/heading_adapter.py,sha256=0X27-N32vt9F67sH9u8wFigfy_Nowgb3quGY4ekW43Y,1390
|
|
12
|
+
resumeforge/adapters/layout_adapter.py,sha256=-4WPZENwvnD_h-SrF5k7InJ5c1IMpDhCW4ETgjQ-F3c,1239
|
|
13
|
+
resumeforge/engines/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
resumeforge/engines/fpdf_engine.py,sha256=zt_t8zcPV2j4JAUnaud4zBA_JD1YkemaCs036z6K5lc,6208
|
|
15
|
+
resumeforge/mappers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
resumeforge/mappers/heading_mapper.py,sha256=krRwv3mQncPt-JuhFwtJhX4fIwGNCa-2dc6ArMsQMLI,900
|
|
17
|
+
resumeforge/mappers/section_mapper.py,sha256=IxejGJcXOy7sSkGoq3_jZtWIo3mzqu4kDJ23FtKDJCs,4260
|
|
18
|
+
resumeforge-0.1.0.dist-info/licenses/LICENSE,sha256=hM-V-L3JzXZRUcJ7bCHfEW7VQFjPeW-TS-9qNRW4lUc,1075
|
|
19
|
+
resumeforge-0.1.0.dist-info/METADATA,sha256=LAdcjQQvLeasTessmpKUKoWY4JU8hGUqFyxWzj9rlMQ,11627
|
|
20
|
+
resumeforge-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
21
|
+
resumeforge-0.1.0.dist-info/entry_points.txt,sha256=zOHEG0Y1URrsCb_7Z1zrjhLdI5lBMgWwIdKEPtkcSPM,53
|
|
22
|
+
resumeforge-0.1.0.dist-info/top_level.txt,sha256=Ma7VdYjmANdBuQVfC2fUGA8unCFtltFe70qizuH8m8Q,12
|
|
23
|
+
resumeforge-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Joseph Strong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
resumeforge
|