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.
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())
@@ -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)
@@ -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)
@@ -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
+ ![build](https://github.com/JohnStrong/ResumeForge/actions/workflows/python-package.yml/badge.svg?branch=main)
27
+ ![coverage](https://codecov.io/gh/JohnStrong/ResumeForge/branch/main/graph/badge.svg)
28
+ ![version](https://img.shields.io/badge/version-0.1.0-blue)
29
+ ![python](https://img.shields.io/badge/python-3.12+-yellow)
30
+ ![license](https://img.shields.io/badge/license-MIT-green)
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
+ ![Resume PDF output](docs/resume.png)
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ resumeforge = resumeforge.cli:main
@@ -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