knowme-careerforge-skill 0.0.2 → 0.0.6
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.
- package/AGENT.md +276 -0
- package/ARCHITECTURE.md +216 -0
- package/README.md +34 -54
- package/README.zh-CN.md +35 -57
- package/SKILL.md +28 -137
- package/agents/claude/knowme-careerforge.md +2 -16
- package/agents/codex/knowme-careerforge.yaml +5 -7
- package/agents/cursor/knowme-careerforge.mdc +4 -9
- package/agents/opencode/skill.yaml +1 -1
- package/agents/windsurf/knowme-careerforge.rules +2 -12
- package/dist/cli/src/commands/init.js +9 -0
- package/dist/cli/src/index.js +40 -19
- package/dist/scripts/rendering/engine-loader.d.ts +2 -0
- package/dist/scripts/rendering/engine-loader.js +46 -0
- package/dist/scripts/rendering/render-pdf.d.ts +3 -8
- package/dist/scripts/rendering/render-pdf.js +7 -103
- package/dist/scripts/rendering/runtime.d.ts +9 -0
- package/dist/scripts/rendering/runtime.js +63 -0
- package/dist/scripts/validation/validate-ats.d.ts +2 -22
- package/dist/scripts/validation/validate-ats.js +6 -168
- package/dist/scripts/validation/validate-layout.d.ts +2 -23
- package/dist/scripts/validation/validate-layout.js +5 -159
- package/package.json +16 -4
- package/references/01-evidence-mining.md +5 -0
- package/references/02-career-goal.md +38 -0
- package/references/03-jd-analysis.md +44 -0
- package/references/04-template-selection.md +43 -0
- package/references/05-html-canvas-tokens.md +12 -0
- package/references/06-qa-and-rendering.md +14 -0
- package/references/07-artifact-contract.md +64 -0
- package/scripts/Agent.md +9 -136
- package/scripts/build/build-gallery.py +66 -56
- package/scripts/build/run-all-tests.py +1 -0
- package/scripts/build/sync-version.py +37 -10
- package/scripts/contracts/profile.py +101 -0
- package/scripts/evidence/extract-evidence.py +109 -62
- package/scripts/pipeline/forge.py +266 -136
- package/scripts/rendering/README.md +65 -0
- package/scripts/rendering/browser-engine.d.ts +15 -0
- package/scripts/rendering/browser-engine.js +366 -0
- package/scripts/rendering/browser_engine.py +39 -0
- package/scripts/rendering/engine-loader.ts +11 -0
- package/scripts/rendering/render-pdf.py +26 -151
- package/scripts/rendering/render-pdf.ts +7 -83
- package/scripts/template/instantiate-resume.py +266 -167
- package/scripts/template/search-template.py +367 -89
- package/scripts/validation/measure-dom.js +5 -0
- package/scripts/validation/validate-ats.ts +6 -174
- package/scripts/validation/validate-layout.ts +5 -159
- package/scripts/validation/validate-resume.py +23 -134
- package/skill.json +1 -1
- package/src/knowledge/index.json +390 -10
- package/src/knowledge/resume-schema.json +377 -71
- package/src/knowledge/templates.json +388 -8
- package/src/references/01-evidence-mining.md +5 -0
- package/src/references/02-career-goal.md +38 -0
- package/src/references/03-jd-analysis.md +44 -0
- package/src/references/04-template-selection.md +43 -0
- package/src/references/05-html-canvas-tokens.md +12 -0
- package/src/references/06-qa-and-rendering.md +14 -0
- package/src/references/07-artifact-contract.md +64 -0
- package/src/templates/Agent.md +11 -0
- package/src/templates/academic-research/README.md +25 -0
- package/src/templates/academic-research/canvas.html +21 -0
- package/src/templates/academic-research/metadata.json +38 -0
- package/src/templates/academic-research/sample-profile.json +86 -0
- package/src/templates/academic-research/style.css +213 -0
- package/src/templates/classic/README.md +9 -0
- package/src/templates/classic/canvas.html +21 -0
- package/src/templates/classic/metadata.json +3 -0
- package/src/templates/classic/sample-profile.json +86 -0
- package/src/templates/classic/style.css +35 -54
- package/src/templates/common/base.css +182 -0
- package/src/templates/common/canvas-bindings.css +41 -0
- package/src/templates/common/resume-contract.md +8 -0
- package/src/templates/compact-dense/README.md +25 -0
- package/src/templates/compact-dense/canvas.html +14 -0
- package/src/templates/compact-dense/metadata.json +38 -0
- package/src/templates/compact-dense/sample-profile.json +86 -0
- package/src/templates/compact-dense/style.css +218 -0
- package/src/templates/creative-tech/README.md +26 -0
- package/src/templates/creative-tech/canvas.html +15 -0
- package/src/templates/creative-tech/metadata.json +39 -0
- package/src/templates/creative-tech/sample-profile.json +86 -0
- package/src/templates/creative-tech/style.css +257 -0
- package/src/templates/data-analyst/README.md +26 -0
- package/src/templates/data-analyst/canvas.html +15 -0
- package/src/templates/data-analyst/metadata.json +39 -0
- package/src/templates/data-analyst/sample-profile.json +86 -0
- package/src/templates/data-analyst/style.css +285 -0
- package/src/templates/executive/README.md +9 -0
- package/src/templates/executive/canvas.html +15 -0
- package/src/templates/executive/sample-profile.json +86 -0
- package/src/templates/executive/style.css +83 -74
- package/src/templates/international-flow/README.md +26 -0
- package/src/templates/international-flow/canvas.html +21 -0
- package/src/templates/international-flow/metadata.json +37 -0
- package/src/templates/international-flow/sample-profile.json +86 -0
- package/src/templates/international-flow/style.css +208 -0
- package/src/templates/minimal/README.md +9 -0
- package/src/templates/minimal/canvas.html +21 -0
- package/src/templates/minimal/sample-profile.json +86 -0
- package/src/templates/minimal/style.css +54 -72
- package/src/templates/modern/README.md +9 -0
- package/src/templates/modern/canvas.html +15 -0
- package/src/templates/modern/sample-profile.json +86 -0
- package/src/templates/modern/style.css +78 -71
- package/src/templates/startup-generalist/README.md +25 -0
- package/src/templates/startup-generalist/canvas.html +21 -0
- package/src/templates/startup-generalist/metadata.json +37 -0
- package/src/templates/startup-generalist/sample-profile.json +86 -0
- package/src/templates/startup-generalist/style.css +237 -0
- package/src/references/ats.md +0 -17
- package/src/references/evidence-first.md +0 -22
- package/src/references/pdf.md +0 -40
- package/src/references/resume-writing.md +0 -25
- package/src/references/template-design.md +0 -57
- package/src/templates/classic/template.html +0 -133
- package/src/templates/executive/template.html +0 -101
- package/src/templates/minimal/template.html +0 -114
- package/src/templates/modern/template.html +0 -139
- package/src/workflows/analyze-job.md +0 -16
- package/src/workflows/define-goal.md +0 -16
- package/src/workflows/forge-resume.md +0 -25
- package/src/workflows/know-me.md +0 -21
- package/src/workflows/position.md +0 -19
- package/src/workflows/review.md +0 -31
|
@@ -1,86 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env ts-node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* 加载自包含 HTML 中间工作区,等待字体及样式准备就绪,输出标准像素级 A4 PDF。
|
|
5
|
-
*/
|
|
2
|
+
import engine = require('./engine-loader');
|
|
3
|
+
import type { EngineOptions, EngineResult } from './browser-engine';
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
interface RenderOptions {
|
|
12
|
-
inputHtml: string;
|
|
13
|
-
outputPdf: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export async function renderResumePdf(options: RenderOptions): Promise<void> {
|
|
17
|
-
const absoluteHtml = path.resolve(options.inputHtml);
|
|
18
|
-
const absolutePdf = path.resolve(options.outputPdf);
|
|
19
|
-
|
|
20
|
-
if (!fs.existsSync(absoluteHtml)) {
|
|
21
|
-
throw new Error(`Input HTML file does not exist: ${absoluteHtml}`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const outputDir = path.dirname(absolutePdf);
|
|
25
|
-
if (!fs.existsSync(outputDir)) {
|
|
26
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
console.log('======================================================');
|
|
30
|
-
console.log(' KnowMe CareerForge — Playwright PDF Renderer');
|
|
31
|
-
console.log(` Source Canvas : ${absoluteHtml}`);
|
|
32
|
-
console.log(` Target PDF : ${absolutePdf}`);
|
|
33
|
-
console.log('======================================================');
|
|
34
|
-
|
|
35
|
-
const browser = await chromium.launch({ headless: true });
|
|
36
|
-
const context = await browser.newContext({
|
|
37
|
-
viewport: { width: 794, height: 1123 }, // A4 at 96 DPI
|
|
38
|
-
deviceScaleFactor: 2
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
const page = await context.newPage();
|
|
42
|
-
await page.goto(`file://${absoluteHtml}`, { waitUntil: 'networkidle' });
|
|
43
|
-
await page.evaluateHandle('document.fonts.ready');
|
|
44
|
-
|
|
45
|
-
await page.pdf({
|
|
46
|
-
path: absolutePdf,
|
|
47
|
-
format: 'A4',
|
|
48
|
-
printBackground: true,
|
|
49
|
-
preferCSSPageSize: true,
|
|
50
|
-
margin: { top: '0px', right: '0px', bottom: '0px', left: '0px' }
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
await browser.close();
|
|
54
|
-
console.log(`[✓] Deterministic PDF Rendered Successfully -> ${absolutePdf}`);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function main() {
|
|
58
|
-
const args = process.argv.slice(2);
|
|
59
|
-
let inputHtml = 'workspace/resume.html';
|
|
60
|
-
let outputPdf = 'workspace/resume.pdf';
|
|
61
|
-
|
|
62
|
-
for (let i = 0; i < args.length; i++) {
|
|
63
|
-
if (args[i] === '--input' || args[i] === '-i') {
|
|
64
|
-
inputHtml = args[i + 1];
|
|
65
|
-
i++;
|
|
66
|
-
} else if (args[i] === '--output' || args[i] === '-o') {
|
|
67
|
-
outputPdf = args[i + 1];
|
|
68
|
-
i++;
|
|
69
|
-
} else if (!args[i].startsWith('-')) {
|
|
70
|
-
if (i === 0) inputHtml = args[i];
|
|
71
|
-
else if (i === 1) outputPdf = args[i];
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
await renderResumePdf({ inputHtml, outputPdf });
|
|
77
|
-
} catch (err: unknown) {
|
|
78
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
79
|
-
console.error(`[✗] PDF Rendering Failed: ${errorMsg}`);
|
|
80
|
-
process.exit(1);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (require.main === module) {
|
|
85
|
-
main();
|
|
5
|
+
export async function renderResumePdf(options: EngineOptions & { inputHtml: string; outputPdf: string }): Promise<EngineResult> {
|
|
6
|
+
const result = await engine.run(options.inputHtml, options);
|
|
7
|
+
if (result.status !== 'PASS') throw new Error(JSON.stringify(result));
|
|
8
|
+
return result;
|
|
86
9
|
}
|
|
10
|
+
if (require.main === module) engine.cli(process.argv.slice(2), { outputPdf: 'workspace/resume.pdf' });
|
|
@@ -1,178 +1,277 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
"""Bind supplied facts to independent blank canvases; publish only complete output.
|
|
3
|
+
|
|
4
|
+
canvas.html owns layout, not candidate content. Each <!-- resume:NAME --> slot
|
|
5
|
+
must occur exactly once. sample-profile.json is gallery-only input, never an automatic fallback.
|
|
6
|
+
The shared profile contract validates and normalizes every input document.
|
|
7
|
+
Omitting --profile deliberately produces a blank canvas.
|
|
5
8
|
"""
|
|
6
9
|
|
|
7
|
-
import
|
|
10
|
+
import argparse
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from html import escape
|
|
13
|
+
from html.parser import HTMLParser
|
|
14
|
+
import json
|
|
15
|
+
import importlib.util
|
|
16
|
+
import os
|
|
8
17
|
from pathlib import Path
|
|
9
|
-
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
|
|
22
|
+
SLOTS = {"styles", "document-title", "basics", "summary", "skills",
|
|
23
|
+
"experience", "projects", "education", "certifications", "language"}
|
|
24
|
+
SLOT = re.compile(r"<!-- resume:([a-z-]+) -->|{{ resume:(language) }}")
|
|
25
|
+
LABELS = {
|
|
26
|
+
"zh": {"document-title": "个人简历", "summary": "个人简介", "skills": "专业技能",
|
|
27
|
+
"experience": "工作经历", "projects": "项目经历", "education": "教育背景", "certifications": "证书"},
|
|
28
|
+
"en": {"document-title": "Resume", "summary": "Summary", "skills": "Skills",
|
|
29
|
+
"experience": "Experience", "projects": "Projects", "education": "Education", "certifications": "Certifications"},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
FONT_PRESETS = {
|
|
35
|
+
"system": "",
|
|
36
|
+
"arial-unicode": """/* Explicit local font: a missing face must fail font QA, not silently pass. */
|
|
37
|
+
@font-face { font-family: "KnowMe Unicode"; src: local("Arial Unicode MS"); font-weight: 400; font-style: normal; }
|
|
38
|
+
:root { --primitive-font-sans: "KnowMe Unicode", Arial, sans-serif; --resume-font-body: "KnowMe Unicode", Arial, sans-serif; }
|
|
39
|
+
""",
|
|
40
|
+
}
|
|
41
|
+
|
|
10
42
|
|
|
11
43
|
def get_project_root() -> Path:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
44
|
+
return Path(__file__).resolve().parents[2]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CanvasContract(HTMLParser):
|
|
48
|
+
"""Reject absent, duplicate, unknown or non-content-context binding slots."""
|
|
49
|
+
|
|
50
|
+
def __init__(self):
|
|
51
|
+
super().__init__(convert_charrefs=True)
|
|
52
|
+
self.stack = []
|
|
53
|
+
self.slots = []
|
|
54
|
+
|
|
55
|
+
def handle_starttag(self, tag, attrs):
|
|
56
|
+
if tag == "html" and not self.stack and attrs.count(("lang", "{{ resume:language }}")) == 1:
|
|
57
|
+
self.slots.append("language")
|
|
58
|
+
if tag not in {"meta", "link", "br", "hr", "img", "input", "wbr"}:
|
|
59
|
+
self.stack.append(tag)
|
|
60
|
+
|
|
61
|
+
def handle_endtag(self, tag):
|
|
62
|
+
if not self.stack or self.stack.pop() != tag:
|
|
63
|
+
raise ValueError(f"Malformed canvas closing tag: {tag}")
|
|
64
|
+
|
|
65
|
+
def handle_comment(self, data):
|
|
66
|
+
if not data.strip().startswith("resume:"):
|
|
67
|
+
return
|
|
68
|
+
name = data.strip()[7:]
|
|
69
|
+
parent = self.stack[-1] if self.stack else None
|
|
70
|
+
valid = (parent == "head" if name == "styles" else
|
|
71
|
+
parent == "title" if name == "document-title" else
|
|
72
|
+
"body" in self.stack and parent in {"main", "aside", "div", "header", "section", "td", "body"})
|
|
73
|
+
if not valid:
|
|
74
|
+
raise ValueError(f"Invalid canvas slot context: {name}")
|
|
75
|
+
self.slots.append(name)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_canvas(canvas):
|
|
79
|
+
parser = CanvasContract()
|
|
80
|
+
parser.feed(canvas)
|
|
81
|
+
parser.close()
|
|
82
|
+
expected = Counter(SLOTS)
|
|
83
|
+
if parser.stack or Counter(parser.slots) != expected or Counter(m.group(1) or m.group(2) for m in SLOT.finditer(canvas)) != expected:
|
|
84
|
+
raise ValueError("Canvas must contain every named resume slot exactly once")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def profile_contract():
|
|
88
|
+
path = get_project_root() / "scripts" / "contracts" / "profile.py"
|
|
89
|
+
spec = importlib.util.spec_from_file_location("careerforge_profile_contract", path)
|
|
90
|
+
module = importlib.util.module_from_spec(spec)
|
|
91
|
+
spec.loader.exec_module(module)
|
|
92
|
+
return module
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def text_field(obj, key):
|
|
96
|
+
value = obj.get(key)
|
|
97
|
+
return "" if value is None else str(value)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def records(obj, key):
|
|
101
|
+
return obj.get(key) or []
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def strings(obj, key):
|
|
105
|
+
return obj.get(key) or []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def highlight_keywords(text, keywords):
|
|
109
|
+
"""Match plain text once, then escape each slice; never search generated HTML."""
|
|
110
|
+
words = sorted({kw.strip() for kw in keywords if kw.strip()}, key=lambda kw: (-len(kw), kw))
|
|
111
|
+
if not words:
|
|
112
|
+
return escape(text, quote=True)
|
|
113
|
+
alternatives = [
|
|
114
|
+
(r"(?<!\w)" if re.match(r"\w", word[0]) else "") + re.escape(word)
|
|
115
|
+
+ (r"(?!\w)" if re.match(r"\w", word[-1]) else "")
|
|
116
|
+
for word in words
|
|
117
|
+
]
|
|
118
|
+
pattern = re.compile("|".join(alternatives), re.I)
|
|
119
|
+
parts, offset = [], 0
|
|
120
|
+
for match in pattern.finditer(text):
|
|
121
|
+
parts.extend((escape(text[offset:match.start()]), "<strong>", escape(match.group()), "</strong>"))
|
|
122
|
+
offset = match.end()
|
|
123
|
+
parts.append(escape(text[offset:]))
|
|
124
|
+
return "".join(parts)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def render_profile_into_html(html_str, profile, keywords=None, *, css=""):
|
|
128
|
+
validate_canvas(html_str)
|
|
129
|
+
profile = profile_contract().normalize_profile(profile)
|
|
130
|
+
language = profile.get("language") or "zh-CN"
|
|
131
|
+
if not re.fullmatch(r"(?:en|zh)(?:-[A-Za-z0-9]{1,8})*", language, re.I):
|
|
132
|
+
raise ValueError("Unsupported canvas language; use an en or zh language tag")
|
|
133
|
+
labels = LABELS[language.split("-")[0].lower()]
|
|
134
|
+
basics = profile.get("basics") or {}
|
|
135
|
+
keywords = keywords or []
|
|
136
|
+
|
|
137
|
+
def element(tag, cls, value):
|
|
138
|
+
return f'<{tag} class="{cls}">{highlight_keywords(value, keywords)}</{tag}>' if value else ""
|
|
139
|
+
|
|
140
|
+
def field(obj, key, cls="fact-text", tag="p"):
|
|
141
|
+
return element(tag, cls, text_field(obj, key))
|
|
142
|
+
|
|
143
|
+
def section(key, content):
|
|
144
|
+
label = labels[key]
|
|
145
|
+
if not content:
|
|
146
|
+
return ""
|
|
147
|
+
return f'<section class="resume-section" id="{key}"><h2 class="section-title">{label}</h2>{content}</section>'
|
|
148
|
+
|
|
149
|
+
def bullets(obj):
|
|
150
|
+
content = "".join(field(b, "text", "fact-bullet", "li") for b in records(obj, "bullets"))
|
|
151
|
+
return f'<ul class="bullet-list">{content}</ul>' if content else ""
|
|
152
|
+
|
|
153
|
+
def dates(obj):
|
|
154
|
+
return element("span", "date-range", " – ".join(filter(None, (text_field(obj, "startDate"), text_field(obj, "endDate")))))
|
|
155
|
+
|
|
156
|
+
def entries(key, name_key):
|
|
157
|
+
rendered = []
|
|
158
|
+
for item in records(profile, key):
|
|
159
|
+
heading = field(item, name_key, "org-name", "span")
|
|
160
|
+
heading += "".join(field(item, k, "role-badge", "span") for k in ("role", "degree", "field"))
|
|
161
|
+
heading += dates(item)
|
|
162
|
+
content = f'<div class="item-header">{heading}</div>' if heading else ""
|
|
163
|
+
content += field(item, "location") + field(item, "summary")
|
|
164
|
+
gpa = item.get("gpa")
|
|
165
|
+
if gpa is not None and gpa != "":
|
|
166
|
+
content += element("p", "edu-detail", f"GPA: {gpa}")
|
|
167
|
+
content += field(item, "repoUrl") + field(item, "demoUrl")
|
|
168
|
+
content += "".join(element("span", "tech-tag", t) for t in strings(item, "techStack"))
|
|
169
|
+
content += "".join(element("p", "edu-detail", honor) for honor in strings(item, "honors"))
|
|
170
|
+
content += bullets(item)
|
|
171
|
+
if content:
|
|
172
|
+
rendered.append(f'<div class="{key[:-1] if key == "projects" else key}-item">{content}</div>')
|
|
173
|
+
return "".join(rendered)
|
|
174
|
+
|
|
175
|
+
name = text_field(basics, "name")
|
|
176
|
+
basic_html = element("h1", "candidate-name", name) + field(basics, "title", "job-target")
|
|
177
|
+
contacts = "".join(field(basics, k, "contact-item", "span") for k in ("phone", "email", "location", "github", "website"))
|
|
178
|
+
if contacts:
|
|
179
|
+
basic_html += f'<div class="contact-grid">{contacts}</div>'
|
|
180
|
+
skill_rows = []
|
|
181
|
+
for skill in records(profile, "skills"):
|
|
182
|
+
category = field(skill, "category", "skill-category", "span")
|
|
183
|
+
items = strings(skill, "items")
|
|
184
|
+
# highlighted is metadata selecting emphasis, never an additional source of facts.
|
|
185
|
+
content = category + element("span", "skill-items", ("; " if language.lower().startswith("en") else "、").join(items))
|
|
186
|
+
if content:
|
|
187
|
+
skill_rows.append(f'<div class="skill-row">{content}</div>')
|
|
188
|
+
values = {
|
|
189
|
+
"styles": f"<style>\n{css}\n</style>",
|
|
190
|
+
"language": escape(language, quote=True),
|
|
191
|
+
"document-title": escape(name + " - " + labels["document-title"] if name else labels["document-title"]),
|
|
192
|
+
"basics": f'<header class="resume-header">{basic_html}</header>' if basic_html else "",
|
|
193
|
+
"summary": section("summary", field(basics, "summary", "value-prop")),
|
|
194
|
+
"skills": section("skills", "".join(skill_rows)),
|
|
195
|
+
"experience": section("experience", entries("experience", "company")),
|
|
196
|
+
"projects": section("projects", entries("projects", "name")),
|
|
197
|
+
"education": section("education", entries("education", "institution")),
|
|
198
|
+
"certifications": section("certifications", "".join(element("p", "fact-text", item) for item in strings(profile, "certifications"))),
|
|
199
|
+
}
|
|
200
|
+
# One substitution pass: user text resembling a slot is never interpreted.
|
|
201
|
+
return SLOT.sub(lambda match: values[match.group(1) or match.group(2)], html_str)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def resolve_template(root, template_id):
|
|
205
|
+
templates = root / "src" / "templates"
|
|
206
|
+
for folder in sorted(templates.iterdir()):
|
|
207
|
+
if not folder.is_dir() or folder.name == "common":
|
|
208
|
+
continue
|
|
209
|
+
if folder.name == template_id:
|
|
210
|
+
return folder
|
|
211
|
+
metadata = folder / "metadata.json"
|
|
212
|
+
if metadata.exists() and json.loads(metadata.read_text(encoding="utf-8")).get("id") == template_id:
|
|
213
|
+
return folder
|
|
214
|
+
raise ValueError(f"Template '{template_id}' not found")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def instantiate_workspace(template_id, profile_path=None, keywords=None, output_path="workspace/resume.html", quiet=False, font_preset="system"):
|
|
218
|
+
if font_preset not in FONT_PRESETS:
|
|
219
|
+
raise ValueError("Unknown font preset: " + str(font_preset))
|
|
220
|
+
root = get_project_root()
|
|
221
|
+
folder = resolve_template(root, template_id)
|
|
222
|
+
canvas = (folder / "canvas.html").read_text(encoding="utf-8")
|
|
223
|
+
common = root / "src" / "templates" / "common"
|
|
224
|
+
css = "\n".join(path.read_text(encoding="utf-8") for path in
|
|
225
|
+
(common / "base.css", folder / "style.css", common / "canvas-bindings.css"))
|
|
226
|
+
css += "\n" + FONT_PRESETS[font_preset]
|
|
227
|
+
profile = json.loads(Path(profile_path).read_text(encoding="utf-8")) if profile_path is not None else {}
|
|
228
|
+
profile = profile_contract().load_document(profile)["profile"]
|
|
229
|
+
rendered = render_profile_into_html(canvas, profile, keywords.split(",") if keywords else [], css=css)
|
|
230
|
+
output = Path(output_path)
|
|
231
|
+
if profile_path is not None and output.resolve() == Path(profile_path).resolve():
|
|
232
|
+
raise ValueError("Output must not overwrite the input profile")
|
|
233
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
temporary = None
|
|
235
|
+
try:
|
|
236
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=output.parent,
|
|
237
|
+
prefix=f".{output.name}.", suffix=".tmp", delete=False) as stream:
|
|
238
|
+
temporary = Path(stream.name)
|
|
239
|
+
stream.write(rendered)
|
|
240
|
+
stream.flush()
|
|
241
|
+
os.fsync(stream.fileno())
|
|
242
|
+
os.replace(temporary, output)
|
|
243
|
+
finally:
|
|
244
|
+
if temporary is not None:
|
|
245
|
+
temporary.unlink(missing_ok=True)
|
|
246
|
+
if not quiet:
|
|
247
|
+
print(f"Canvas written: {output.resolve()}")
|
|
248
|
+
return output
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class CLIParser(argparse.ArgumentParser):
|
|
252
|
+
def error(self, message):
|
|
253
|
+
raise ValueError(message)
|
|
254
|
+
|
|
166
255
|
|
|
167
256
|
def main():
|
|
168
|
-
parser =
|
|
169
|
-
parser.add_argument("--template", "-t", required=True, help="Template
|
|
170
|
-
parser.add_argument("--profile", "-p",
|
|
171
|
-
parser.add_argument("--keywords", "-k",
|
|
172
|
-
parser.add_argument("--output", "-o", default="workspace/resume.html"
|
|
257
|
+
parser = CLIParser(description=__doc__)
|
|
258
|
+
parser.add_argument("--template", "-t", required=True, help="Template directory name or metadata ID")
|
|
259
|
+
parser.add_argument("--profile", "-p", help="Candidate JSON; omitted produces a blank canvas")
|
|
260
|
+
parser.add_argument("--keywords", "-k", help="Comma-separated plain-text keywords")
|
|
261
|
+
parser.add_argument("--output", "-o", default="workspace/resume.html")
|
|
262
|
+
parser.add_argument("--font-preset", choices=sorted(FONT_PRESETS), default="system")
|
|
263
|
+
parser.add_argument("--quiet", "-q", action="store_true", help="Suppress human diagnostics; JSON result is always emitted")
|
|
264
|
+
result = {"status": "FAIL", "errors": [], "warnings": [], "checks": {"canvasWritten": False}}
|
|
265
|
+
try:
|
|
266
|
+
args = parser.parse_args()
|
|
267
|
+
output = instantiate_workspace(args.template, args.profile, args.keywords, args.output, quiet=True, font_preset=args.font_preset)
|
|
268
|
+
result.update(status="PASS", output=str(output.resolve()))
|
|
269
|
+
result["checks"]["canvasWritten"] = True
|
|
270
|
+
except (OSError, ValueError, TypeError) as error:
|
|
271
|
+
result["errors"].append(str(error))
|
|
272
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
273
|
+
return 0 if result["status"] == "PASS" else 1
|
|
173
274
|
|
|
174
|
-
args = parser.parse_args()
|
|
175
|
-
instantiate_workspace(args.template, profile_path=args.profile, keywords=args.keywords, output_path=args.output)
|
|
176
275
|
|
|
177
276
|
if __name__ == "__main__":
|
|
178
|
-
main()
|
|
277
|
+
sys.exit(main())
|