knowme-careerforge-skill 0.0.4 → 0.0.7
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 +220 -0
- package/README.md +46 -63
- package/README.zh-CN.md +47 -66
- package/SKILL.md +38 -135
- package/agents/claude/knowme-careerforge.md +2 -17
- package/agents/codex/knowme-careerforge.yaml +2 -20
- package/agents/cursor/knowme-careerforge.mdc +4 -13
- package/agents/gemini/knowme-careerforge.json +1 -1
- package/agents/opencode/skill.yaml +1 -1
- package/agents/windsurf/knowme-careerforge.rules +2 -12
- package/dist/cli/src/commands/init.js +51 -32
- package/dist/cli/src/index.js +25 -21
- 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 +15 -4
- package/references/01-evidence-mining.md +35 -0
- package/references/02-career-goal.md +40 -0
- package/references/03-jd-analysis.md +32 -0
- package/references/04-template-selection.md +30 -0
- package/references/05-html-canvas-tokens.md +12 -0
- package/references/06-qa-and-rendering.md +27 -0
- package/references/07-artifact-contract.md +83 -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 +27 -9
- package/scripts/contracts/profile.py +101 -0
- package/scripts/evidence/extract-evidence.py +109 -62
- package/scripts/pipeline/forge.py +276 -136
- package/scripts/rendering/README.md +65 -0
- package/scripts/rendering/browser-engine.d.ts +15 -0
- package/scripts/rendering/browser-engine.js +408 -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 -173
- package/scripts/template/search-template.py +89 -3
- 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 +7 -6
- package/src/knowledge/index.json +391 -3
- package/src/knowledge/resume-schema.json +377 -71
- package/src/knowledge/templates.json +389 -1
- package/src/references/01-evidence-mining.md +24 -30
- package/src/references/02-career-goal.md +4 -2
- package/src/references/03-jd-analysis.md +5 -17
- package/src/references/04-template-selection.md +20 -33
- package/src/references/05-html-canvas-tokens.md +6 -45
- package/src/references/06-qa-and-rendering.md +16 -43
- package/src/references/07-artifact-contract.md +83 -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 +14 -4
- package/src/templates/common/base.css +32 -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 +58 -6
- 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 +12 -1
- package/src/templates/minimal/canvas.html +21 -0
- package/src/templates/minimal/sample-profile.json +86 -0
- package/src/templates/minimal/style.css +24 -0
- 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 +48 -1
- 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/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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Browser rendering and validation
|
|
2
|
+
|
|
3
|
+
All rendering/layout entry points delegate to `browser-engine.js`:
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
const { run } = require('./browser-engine');
|
|
7
|
+
const result = await run(htmlPath, { expectedPages: 1, autoHeal: false, outputPdf });
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
node scripts/rendering/browser-engine.js resume.html --expected-pages 1 --output resume.pdf
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
CLI stdout is one JSON object with `status`, `errors`, `warnings`, `checks`,
|
|
15
|
+
`file`, and `expectedPages`. Exit codes: PASS=0, FAIL=1, UNVERIFIED=2.
|
|
16
|
+
`expectedPages` is an upper limit, restricted to 1 or 2.
|
|
17
|
+
Missing dependencies, browser/font/resource failures and parser failures cannot pass.
|
|
18
|
+
Validation without `outputPdf` still generates and inspects a fresh in-memory PDF.
|
|
19
|
+
No previous PDF is read to decide success. Output write/publication failures are
|
|
20
|
+
FAIL (exit 1); UNVERIFIED is reserved for unavailable verification facilities.
|
|
21
|
+
On successful export, `checks.output` contains `status: "PASS"`, `committed: true`,
|
|
22
|
+
the absolute `path`, and lowercase SHA-256 `pdfSha256` / `htmlSha256` digests of
|
|
23
|
+
the exact published PDF and final source HTML bytes. Without export, `committed`
|
|
24
|
+
is false and `path` is null, while digests identify the verified in-memory PDF
|
|
25
|
+
and final HTML.
|
|
26
|
+
|
|
27
|
+
Runtime dependencies: Playwright (or playwright-core with a browser), pdf-lib,
|
|
28
|
+
and pdfjs-dist with `legacy/build/pdf.mjs`; use a Node version supported by
|
|
29
|
+
pdfjs-dist. `CHROME_BIN` / `BROWSER_PATH` can select a Chromium executable.
|
|
30
|
+
Without overrides, bundled Chromium is tried first, then detected system
|
|
31
|
+
Chrome/Chromium/Edge/Brave installations (including PATH and macOS/Windows locations).
|
|
32
|
+
Dependency installation and package manifests are managed separately.
|
|
33
|
+
|
|
34
|
+
Checks use print media, await fonts and images, and inspect every `.resume-page`,
|
|
35
|
+
its descendants and text ranges against all four boundaries and rectangular
|
|
36
|
+
ancestor clips. Exactly one nonempty visible `h1.candidate-name` is required,
|
|
37
|
+
Nonempty print-hidden body text (display, visibility or ancestor opacity) fails,
|
|
38
|
+
even if other body text still survives PDF extraction. Also required:
|
|
39
|
+
at least 20 non-whitespace body characters beyond the name with no unbound
|
|
40
|
+
resume slot comments or language attribute. Literal brace syntax in user text is preserved. Each PDF page must correspond to an explicit `.resume-page`;
|
|
41
|
+
a single root naturally flowing into multiple PDF pages is rejected. DOM tolerance is 0.75 CSS pixels. Unsupported clipping returns
|
|
42
|
+
UNVERIFIED. The generated PDF must have 1–`expectedPages` portrait A4
|
|
43
|
+
pages (1 PDF point tolerance, including crop boxes). PDF.js extracts every page:
|
|
44
|
+
normalized character multiplicities and each DOM text fragment must survive on
|
|
45
|
+
the corresponding PDF page. NFKC and whitespace/control normalization tolerate
|
|
46
|
+
line wrapping and ligatures; a missing or reordered name cannot pass.
|
|
47
|
+
|
|
48
|
+
Autoheal only reduces existing numeric inline `:root` tokens through the bounded
|
|
49
|
+
ladder. Each candidate is loaded from a temporary sibling HTML file to preserve
|
|
50
|
+
relative assets and fully revalidated. No source change is published on failed
|
|
51
|
+
validation. On success, files are replaced by same-directory renames; an HTML
|
|
52
|
+
backup allows rollback if PDF publication fails. This is per-file atomicity with
|
|
53
|
+
rollback, not a crash-atomic transaction spanning two files. Temporary candidates
|
|
54
|
+
are cleaned up. Concurrent source edits detected before publication abort the run.
|
|
55
|
+
|
|
56
|
+
Isolated tests (real Chromium, temporary directories and injected PDF/I/O failures):
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
node --test tests/rendering/browser-engine.test.js
|
|
60
|
+
python3 scripts/build/run-all-tests.py
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The Python suite runs the Node regressions through `tests/validation/test_auto_heal.py`.
|
|
64
|
+
|
|
65
|
+
Per-page text diagnostics include missingCodepoints and extractedRadicals (glyph and U+ codepoint). A warning about possible font mapping differences never changes FAIL to PASS. The pipeline and instantiator accept --font-preset arial-unicode to explicitly use locally installed Arial Unicode MS; this is not a browser-engine option. Missing explicit fonts remain UNVERIFIED.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface EngineOptions {
|
|
2
|
+
expectedPages?: number;
|
|
3
|
+
autoHeal?: boolean;
|
|
4
|
+
outputPdf?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface EngineResult {
|
|
7
|
+
status: 'PASS' | 'FAIL' | 'UNVERIFIED';
|
|
8
|
+
file: string;
|
|
9
|
+
expectedPages: number;
|
|
10
|
+
errors: string[];
|
|
11
|
+
warnings: string[];
|
|
12
|
+
checks: Record<string, { status: 'PASS' | 'FAIL' | 'UNVERIFIED'; [key: string]: unknown }>;
|
|
13
|
+
}
|
|
14
|
+
export function run(html: string, options?: EngineOptions): Promise<EngineResult>;
|
|
15
|
+
export function cli(args?: string[], defaults?: EngineOptions): Promise<EngineResult>;
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs/promises');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { pathToFileURL } = require('node:url');
|
|
7
|
+
const { randomUUID, createHash } = require('node:crypto');
|
|
8
|
+
|
|
9
|
+
const CHECKS = ['input', 'fonts', 'dom', 'pdf', 'text', 'output'];
|
|
10
|
+
function resultFor(html, expectedPages) {
|
|
11
|
+
return { status: 'UNVERIFIED', file: path.resolve(html), expectedPages,
|
|
12
|
+
errors: [], warnings: [], checks: Object.fromEntries(CHECKS.map(k => [k, { status: 'UNVERIFIED' }])) };
|
|
13
|
+
}
|
|
14
|
+
function failure(result, check, message, status = 'FAIL') {
|
|
15
|
+
result.checks[check] = { ...result.checks[check], status };
|
|
16
|
+
result.errors.push(`${check}: ${message}`);
|
|
17
|
+
}
|
|
18
|
+
function finish(result) {
|
|
19
|
+
result.status = result.errors.length
|
|
20
|
+
? (Object.values(result.checks).some(c => c.status === 'FAIL') ? 'FAIL' : 'UNVERIFIED')
|
|
21
|
+
: Object.values(result.checks).every(c => c.status === 'PASS') ? 'PASS' : 'UNVERIFIED';
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Executed in the print-media page. Never relax height/overflow constraints.
|
|
26
|
+
function inspectDOM() {
|
|
27
|
+
const tolerance = 0.75;
|
|
28
|
+
const pages = [...document.querySelectorAll('.resume-page')];
|
|
29
|
+
const issues = [];
|
|
30
|
+
const unsupported = [];
|
|
31
|
+
const rect = r => ({ left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height });
|
|
32
|
+
const label = e => e.id ? `#${e.id}` : e.tagName.toLowerCase() + (e.classList.length ? '.' + [...e.classList].join('.') : '');
|
|
33
|
+
function visible(e) {
|
|
34
|
+
for (let a = e; a; a = a.parentElement) {
|
|
35
|
+
const s = getComputedStyle(a);
|
|
36
|
+
if (s.display === 'none' || Number(s.opacity) === 0) return false;
|
|
37
|
+
}
|
|
38
|
+
return getComputedStyle(e).visibility === 'visible';
|
|
39
|
+
}
|
|
40
|
+
const details = pages.map((p, i) => {
|
|
41
|
+
const bounds = p.getBoundingClientRect();
|
|
42
|
+
const pageIndex = i + 1;
|
|
43
|
+
if (!visible(p) || !bounds.width || !bounds.height) issues.push({ pageIndex, selector: label(p), reason: 'invisible/empty page' });
|
|
44
|
+
if (bounds.width > 210 * 96 / 25.4 + tolerance || bounds.height > 297 * 96 / 25.4 + tolerance)
|
|
45
|
+
issues.push({ pageIndex, selector: label(p), reason: 'page exceeds A4', bounds: rect(bounds) });
|
|
46
|
+
function check(r, e, isPage = false) {
|
|
47
|
+
const sides = [];
|
|
48
|
+
if (r.left < bounds.left - tolerance) sides.push('left');
|
|
49
|
+
if (r.top < bounds.top - tolerance) sides.push('top');
|
|
50
|
+
if (r.right > bounds.right + tolerance) sides.push('right');
|
|
51
|
+
if (r.bottom > bounds.bottom + tolerance) sides.push('bottom');
|
|
52
|
+
if (sides.length) issues.push({ pageIndex, selector: label(e), reason: 'page boundary', sides, bounds: rect(r) });
|
|
53
|
+
for (let a = isPage ? e.parentElement : e; a; a = a.parentElement) {
|
|
54
|
+
const s = getComputedStyle(a);
|
|
55
|
+
const b = a.getBoundingClientRect();
|
|
56
|
+
const left = b.left + a.clientLeft, top = b.top + a.clientTop;
|
|
57
|
+
const clipX = /hidden|clip|scroll|auto/.test(s.overflowX);
|
|
58
|
+
const clipY = /hidden|clip|scroll|auto/.test(s.overflowY);
|
|
59
|
+
const paint = /paint|strict|content/.test(s.contain);
|
|
60
|
+
if (((clipX || paint) && (r.left < left - tolerance || r.right > left + a.clientWidth + tolerance)) ||
|
|
61
|
+
((clipY || paint) && (r.top < top - tolerance || r.bottom > top + a.clientHeight + tolerance)))
|
|
62
|
+
issues.push({ pageIndex, selector: label(e), ancestor: label(a), reason: 'ancestor clipping', bounds: rect(r) });
|
|
63
|
+
if (s.clipPath !== 'none' || (s.maskImage && s.maskImage !== 'none') || s.clip !== 'auto')
|
|
64
|
+
unsupported.push({ pageIndex, selector: label(a), reason: 'non-rectangular/legacy clipping requires visual verification' });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
check(bounds, p, true);
|
|
68
|
+
const text = [];
|
|
69
|
+
for (const e of [p, ...p.querySelectorAll('*')]) {
|
|
70
|
+
if (/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(e.tagName)) continue;
|
|
71
|
+
if (!visible(e)) {
|
|
72
|
+
if ([...e.childNodes].some(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim()))
|
|
73
|
+
issues.push({ pageIndex, selector: label(e), reason: 'Hidden nonempty body text in print media' });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (e !== p) for (const r of e.getClientRects()) check(r, e.parentElement);
|
|
77
|
+
for (const node of e.childNodes) {
|
|
78
|
+
if (node.nodeType !== Node.TEXT_NODE || !node.textContent.trim()) continue;
|
|
79
|
+
const range = document.createRange();
|
|
80
|
+
range.selectNodeContents(node);
|
|
81
|
+
const rs = [...range.getClientRects()];
|
|
82
|
+
if (!rs.length) continue;
|
|
83
|
+
text.push(node.textContent);
|
|
84
|
+
for (const r of rs) check(r, e);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { pageIndex, bounds: rect(bounds), text: text.join(' '), textFragments: text };
|
|
88
|
+
});
|
|
89
|
+
const names = [...document.querySelectorAll('h1.candidate-name')];
|
|
90
|
+
const validName = names.length === 1 && visible(names[0]) && names[0].getBoundingClientRect().width > 0 && names[0].getBoundingClientRect().height > 0 && !!names[0].innerText.replace(/[\s\u200b-\u200f\ufeff]/g, '') && !!names[0].closest('.resume-page');
|
|
91
|
+
if (!validName) issues.push({ reason: 'Require exactly one nonempty visible h1.candidate-name inside a resume page' });
|
|
92
|
+
const compact = text => text.replace(/\s/g, '');
|
|
93
|
+
const substantive = compact(details.map(p => p.text).join('')).replace(validName ? compact(names[0].innerText) : '', '');
|
|
94
|
+
if (substantive.length < 20) issues.push({ reason: 'Require at least 20 non-whitespace body characters beyond the name' });
|
|
95
|
+
const comments = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT);
|
|
96
|
+
while (comments.nextNode()) {
|
|
97
|
+
if (/^\s*resume:/.test(comments.currentNode.data))
|
|
98
|
+
issues.push({ reason: 'Unbound canvas slot remains in the document' });
|
|
99
|
+
}
|
|
100
|
+
if (/\{\{\s*resume:language\s*\}\}/.test(document.documentElement.lang))
|
|
101
|
+
issues.push({ reason: 'Unbound canvas language slot' });
|
|
102
|
+
return { totalPagesFound: pages.length, pages: details, issues, unsupported, candidateName: validName ? names[0].innerText.trim() : null, bodyCharacters: substantive.length };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalized(text) {
|
|
106
|
+
return text.normalize('NFKC').replace(/[\s\u200b-\u200f\u202a-\u202e\u2060\ufeff\u00ad]/gu, '');
|
|
107
|
+
}
|
|
108
|
+
// Compare character multiplicities, tolerating PDF column/RTL extraction order only.
|
|
109
|
+
function missingText(expected, actual) {
|
|
110
|
+
const counts = new Map();
|
|
111
|
+
for (const c of normalized(actual)) counts.set(c, (counts.get(c) || 0) + 1);
|
|
112
|
+
let missing = '';
|
|
113
|
+
for (const c of normalized(expected)) {
|
|
114
|
+
if ((counts.get(c) || 0) > 0) counts.set(c, counts.get(c) - 1);
|
|
115
|
+
else missing += c;
|
|
116
|
+
}
|
|
117
|
+
return missing;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ladder = [
|
|
121
|
+
...[10.5, 9.5, 8.5].map(n => ({ '--resume-space-section': `${n}pt` })),
|
|
122
|
+
...[[7, 2.5], [6, 2], [5, 1.5]].map(([a, b]) => ({ '--resume-space-item': `${a}pt`, '--resume-space-bullet': `${b}pt` })),
|
|
123
|
+
...[[9, 1.42], [8.8, 1.38]].map(([a, b]) => ({ '--resume-font-size-body': `${a}pt`, '--resume-line-height-body': `${b}` }))
|
|
124
|
+
];
|
|
125
|
+
// Only edit existing numeric root declarations, never insert or increase a token.
|
|
126
|
+
function reduceTokens(html, targets) {
|
|
127
|
+
const tuned = {};
|
|
128
|
+
const content = html.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (all, open, css, close) =>
|
|
129
|
+
open + css.replace(/(:root\s*\{)([^}]*)(\})/g, (block, start, body, end) => {
|
|
130
|
+
for (const [token, value] of Object.entries(targets)) {
|
|
131
|
+
const target = /^([\d.]+)(pt)?$/.exec(value);
|
|
132
|
+
body = body.replace(new RegExp(`(${token}\\s*:\\s*)([\\d.]+)(pt)?(\\s*;)`, 'g'), (decl, prefix, n, unit, semi) => {
|
|
133
|
+
if ((unit || '') !== (target[2] || '') || Number(n) <= Number(target[1])) return decl;
|
|
134
|
+
tuned[token] = value;
|
|
135
|
+
return prefix + value + semi;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return start + body + end;
|
|
139
|
+
}) + close);
|
|
140
|
+
return { content, tuned };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function dependencies() {
|
|
144
|
+
let pw;
|
|
145
|
+
try { pw = require('playwright'); } catch { pw = require('playwright-core'); }
|
|
146
|
+
const { PDFDocument } = require('pdf-lib');
|
|
147
|
+
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
|
148
|
+
return { pw, PDFDocument, pdfjs };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Readiness does not generate candidate artifacts or certify a resume.
|
|
152
|
+
async function checkRuntime() {
|
|
153
|
+
const result = { status: 'UNVERIFIED', stage: 'runtime', errors: [], checks: {} };
|
|
154
|
+
const version = process.versions.node.split('.').map(Number);
|
|
155
|
+
if (version[0] < 22 || (version[0] === 22 && version[1] < 13)) {
|
|
156
|
+
result.errors.push('Node.js 22.13+ is required');
|
|
157
|
+
}
|
|
158
|
+
for (const [name, candidates] of [
|
|
159
|
+
['playwright', ['playwright', 'playwright-core']],
|
|
160
|
+
['pdf-lib', ['pdf-lib']],
|
|
161
|
+
['pdfjs-dist', ['pdfjs-dist/legacy/build/pdf.mjs']]
|
|
162
|
+
]) {
|
|
163
|
+
const available = candidates.some(candidate => {
|
|
164
|
+
try { require.resolve(candidate); return true; } catch { return false; }
|
|
165
|
+
});
|
|
166
|
+
result.checks[name] = available ? 'AVAILABLE' : 'MISSING';
|
|
167
|
+
if (!available) result.errors.push(`Missing dependency: ${name}`);
|
|
168
|
+
}
|
|
169
|
+
if (!result.errors.length) {
|
|
170
|
+
let browser;
|
|
171
|
+
try {
|
|
172
|
+
const deps = await dependencies();
|
|
173
|
+
browser = await launchBrowser(deps.pw);
|
|
174
|
+
result.checks.browser = browser.version();
|
|
175
|
+
result.status = 'READY';
|
|
176
|
+
} catch (error) { result.errors.push(error.message); }
|
|
177
|
+
finally { if (browser) await browser.close(); }
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function systemBrowsers() {
|
|
183
|
+
const home = require('node:os').homedir();
|
|
184
|
+
const candidates = [
|
|
185
|
+
...['/Applications', path.join(home, 'Applications')].flatMap(base => [
|
|
186
|
+
'Google Chrome.app/Contents/MacOS/Google Chrome', 'Chromium.app/Contents/MacOS/Chromium',
|
|
187
|
+
'Microsoft Edge.app/Contents/MacOS/Microsoft Edge', 'Brave Browser.app/Contents/MacOS/Brave Browser'
|
|
188
|
+
].map(name => path.join(base, name))),
|
|
189
|
+
...[process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].filter(Boolean).flatMap(base => [
|
|
190
|
+
'Google/Chrome/Application/chrome.exe', 'Microsoft/Edge/Application/msedge.exe', 'BraveSoftware/Brave-Browser/Application/brave.exe'
|
|
191
|
+
].map(name => path.join(base, name))),
|
|
192
|
+
...(process.env.PATH || '').split(path.delimiter).filter(Boolean).flatMap(base =>
|
|
193
|
+
['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge', 'brave-browser'].map(name => path.join(base, name)))
|
|
194
|
+
];
|
|
195
|
+
const found = [];
|
|
196
|
+
for (const candidate of new Set(candidates)) {
|
|
197
|
+
try { await fs.access(candidate, require('node:fs').constants.X_OK); found.push(candidate); } catch {}
|
|
198
|
+
}
|
|
199
|
+
return found;
|
|
200
|
+
}
|
|
201
|
+
async function launchBrowser(pw) {
|
|
202
|
+
const override = process.env.CHROME_BIN || process.env.BROWSER_PATH;
|
|
203
|
+
if (override) return pw.chromium.launch({ headless: true, executablePath: override });
|
|
204
|
+
const failures = [];
|
|
205
|
+
for (const executablePath of [undefined, ...await systemBrowsers()]) {
|
|
206
|
+
try { return await pw.chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }); }
|
|
207
|
+
catch (error) { failures.push(`${executablePath || 'bundled Chromium'}: ${error.message}`); }
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`No usable browser: ${failures.join('\n')}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function inspect(page, htmlFile, expectedPages, deps) {
|
|
213
|
+
const result = resultFor(htmlFile, expectedPages);
|
|
214
|
+
result.checks.input = { status: 'PASS' };
|
|
215
|
+
let phase = 'fonts';
|
|
216
|
+
const resourceErrors = [];
|
|
217
|
+
const onFailed = request => resourceErrors.push(request.url());
|
|
218
|
+
const onResponse = response => { if (response.status() >= 400) resourceErrors.push(response.url()); };
|
|
219
|
+
page.on('requestfailed', onFailed);
|
|
220
|
+
page.on('response', onResponse);
|
|
221
|
+
try {
|
|
222
|
+
await page.emulateMedia({ media: 'print' });
|
|
223
|
+
await page.goto(pathToFileURL(htmlFile).href, { waitUntil: 'networkidle', timeout: 30000 });
|
|
224
|
+
await page.evaluate(async () => {
|
|
225
|
+
await Promise.race([document.fonts.ready, new Promise((_, reject) => setTimeout(() => reject(new Error('Font wait timed out')), 15000))]);
|
|
226
|
+
if ([...document.fonts].some(f => f.status === 'error')) throw new Error('A font failed to load');
|
|
227
|
+
await Promise.all([...document.images].map(img => img.decode()));
|
|
228
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
229
|
+
});
|
|
230
|
+
if (resourceErrors.length) throw new Error(`Resources failed to load: ${resourceErrors.join(', ')}`);
|
|
231
|
+
result.checks.fonts = { status: 'PASS' };
|
|
232
|
+
phase = 'dom';
|
|
233
|
+
const dom = await page.evaluate(inspectDOM);
|
|
234
|
+
result.checks.dom = { status: 'PASS', ...dom };
|
|
235
|
+
if (dom.totalPagesFound < 1 || dom.totalPagesFound > expectedPages) failure(result, 'dom', `Require 1–${expectedPages} explicit .resume-page containers, found ${dom.totalPagesFound}`);
|
|
236
|
+
if (dom.issues.length) failure(result, 'dom', `${dom.issues.length} boundary/clipping violations`);
|
|
237
|
+
if (dom.unsupported.length && !dom.issues.length) failure(result, 'dom', 'Unsupported clipping', 'UNVERIFIED');
|
|
238
|
+
phase = 'pdf';
|
|
239
|
+
const bytes = await page.pdf({ format: 'A4', preferCSSPageSize: true, printBackground: true,
|
|
240
|
+
displayHeaderFooter: false, margin: { top: 0, right: 0, bottom: 0, left: 0 } });
|
|
241
|
+
const pdf = await deps.PDFDocument.load(bytes);
|
|
242
|
+
const sizes = pdf.getPages().map((p, i) => ({ pageIndex: i + 1, ...p.getSize(), cropBox: p.getCropBox(), rotation: p.getRotation().angle }));
|
|
243
|
+
result.checks.pdf = { status: 'PASS', pageCount: sizes.length, pages: sizes };
|
|
244
|
+
if (sizes.length < 1 || sizes.length > expectedPages) failure(result, 'pdf', `Page limit ${expectedPages}, generated ${sizes.length}`);
|
|
245
|
+
if (sizes.length !== dom.totalPagesFound) failure(result, 'pdf', 'Each physical PDF page must have its own explicit .resume-page container; flowing one container over multiple pages is unsupported');
|
|
246
|
+
if (sizes.some(p => Math.abs(p.width - 595.276) > 1 || Math.abs(p.height - 841.89) > 1 || p.rotation % 360 !== 0 || Math.abs(p.cropBox.x) > 1 || Math.abs(p.cropBox.y) > 1 || Math.abs(p.cropBox.width - p.width) > 1 || Math.abs(p.cropBox.height - p.height) > 1))
|
|
247
|
+
failure(result, 'pdf', 'Every PDF page must be portrait A4 (1pt tolerance)');
|
|
248
|
+
phase = 'text';
|
|
249
|
+
const task = deps.pdfjs.getDocument({ data: new Uint8Array(bytes), useSystemFonts: true, verbosity: 0 });
|
|
250
|
+
try {
|
|
251
|
+
const doc = await task.promise;
|
|
252
|
+
const pages = [];
|
|
253
|
+
for (let n = 1; n <= doc.numPages; n++) {
|
|
254
|
+
const content = await (await doc.getPage(n)).getTextContent();
|
|
255
|
+
const text = content.items.map(item => item.str || '').join(' ');
|
|
256
|
+
const expected = dom.pages[n - 1]?.text || '';
|
|
257
|
+
const missing = missingText(expected, text);
|
|
258
|
+
const missingFragments = (dom.pages[n - 1]?.textFragments || [])
|
|
259
|
+
.map(normalized).filter(fragment => fragment && !normalized(text).includes(fragment));
|
|
260
|
+
const codepoints = value => [...new Set([...value])].map(character => ({
|
|
261
|
+
character, codepoint: 'U+' + character.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')
|
|
262
|
+
}));
|
|
263
|
+
const extractedRadicals = [...text].filter(character => {
|
|
264
|
+
const cp = character.codePointAt(0); return cp >= 0x2E80 && cp <= 0x2FDF;
|
|
265
|
+
}).join('');
|
|
266
|
+
pages.push({ pageIndex: n, text, expectedCharacters: normalized(expected).length, missing, missingFragments,
|
|
267
|
+
missingCodepoints: codepoints(missing), extractedRadicals: codepoints(extractedRadicals) });
|
|
268
|
+
if (missing && extractedRadicals)
|
|
269
|
+
result.warnings.push(`Page ${n}: extracted radical codepoints may indicate font mapping differences. Inspect missingCodepoints/extractedRadicals; choose an available explicit font and rerun without changing the facts.`);
|
|
270
|
+
}
|
|
271
|
+
result.checks.text = { status: 'PASS', pages };
|
|
272
|
+
if (doc.numPages !== sizes.length || pages.some(p => !normalized(p.text) || !p.expectedCharacters || p.missing || p.missingFragments.length || p.text.includes('\ufffd')))
|
|
273
|
+
failure(result, 'text', 'Blank, missing, undecodable or mismatched per-page PDF text');
|
|
274
|
+
} finally { await task.destroy(); }
|
|
275
|
+
result.checks.output = { status: 'PASS', committed: false };
|
|
276
|
+
return { result: finish(result), bytes };
|
|
277
|
+
} catch (error) {
|
|
278
|
+
failure(result, phase, error.message, 'UNVERIFIED');
|
|
279
|
+
return { result: finish(result) };
|
|
280
|
+
} finally {
|
|
281
|
+
page.off('requestfailed', onFailed);
|
|
282
|
+
page.off('response', onResponse);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function run(html, { expectedPages = 1, autoHeal = false, outputPdf } = {}) {
|
|
287
|
+
const initial = resultFor(html, expectedPages);
|
|
288
|
+
const source = initial.file;
|
|
289
|
+
let browser, stagedHtml, stagedPdf, backupHtml;
|
|
290
|
+
let phase = 'input';
|
|
291
|
+
try {
|
|
292
|
+
if (![1, 2].includes(expectedPages)) {
|
|
293
|
+
failure(initial, 'input', 'expectedPages must be the page limit 1 or 2'); return finish(initial);
|
|
294
|
+
}
|
|
295
|
+
const originalBytes = await fs.readFile(source);
|
|
296
|
+
const original = originalBytes.toString('utf8');
|
|
297
|
+
const output = outputPdf ? path.resolve(outputPdf) : null;
|
|
298
|
+
if (output && (output === source || await fs.realpath(output).catch(() => output) === await fs.realpath(source))) {
|
|
299
|
+
failure(initial, 'input', 'PDF output must differ from HTML source'); return finish(initial);
|
|
300
|
+
}
|
|
301
|
+
initial.checks.input = { status: 'PASS' };
|
|
302
|
+
phase = 'fonts';
|
|
303
|
+
const deps = await dependencies();
|
|
304
|
+
browser = await launchBrowser(deps.pw);
|
|
305
|
+
const page = await browser.newPage({ viewport: { width: 794, height: 1123 } });
|
|
306
|
+
let attempt = await inspect(page, source, expectedPages, deps);
|
|
307
|
+
let candidate = original;
|
|
308
|
+
const tunedTokens = {};
|
|
309
|
+
let iterations = 0;
|
|
310
|
+
if (autoHeal && attempt.result.status === 'FAIL' && attempt.result.checks.dom.issues?.length) {
|
|
311
|
+
stagedHtml = path.join(path.dirname(source), `.${path.basename(source)}.${randomUUID()}.html`);
|
|
312
|
+
for (const targets of ladder) {
|
|
313
|
+
const reduced = reduceTokens(candidate, targets);
|
|
314
|
+
if (reduced.content === candidate) continue;
|
|
315
|
+
candidate = reduced.content;
|
|
316
|
+
Object.assign(tunedTokens, reduced.tuned);
|
|
317
|
+
await fs.writeFile(stagedHtml, candidate, { mode: (await fs.stat(source)).mode });
|
|
318
|
+
iterations++;
|
|
319
|
+
attempt = await inspect(page, stagedHtml, expectedPages, deps);
|
|
320
|
+
if (attempt.result.status !== 'FAIL') break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
await browser.close(); browser = null;
|
|
324
|
+
const result = attempt.result;
|
|
325
|
+
result.file = source;
|
|
326
|
+
result.checks.auto_heal = { status: result.status, requested: autoHeal, healed: result.status === 'PASS', iterations,
|
|
327
|
+
tunedTokens: result.status === 'PASS' ? tunedTokens : {}, committed: false };
|
|
328
|
+
if (result.status !== 'PASS') return result;
|
|
329
|
+
phase = 'output';
|
|
330
|
+
// All validation and browser shutdown precede publication. Each file is replaced
|
|
331
|
+
// by a same-directory rename; roll back HTML if the PDF rename fails.
|
|
332
|
+
try {
|
|
333
|
+
if (!(await fs.readFile(source)).equals(originalBytes)) throw new Error('HTML changed during validation');
|
|
334
|
+
if (output) {
|
|
335
|
+
await fs.mkdir(path.dirname(output), { recursive: true });
|
|
336
|
+
stagedPdf = path.join(path.dirname(output), `.${path.basename(output)}.${randomUUID()}.tmp`);
|
|
337
|
+
await fs.writeFile(stagedPdf, attempt.bytes, { flag: 'wx' });
|
|
338
|
+
}
|
|
339
|
+
if (iterations) {
|
|
340
|
+
backupHtml = path.join(path.dirname(source), `.${path.basename(source)}.${randomUUID()}.backup`);
|
|
341
|
+
await fs.copyFile(source, backupHtml);
|
|
342
|
+
}
|
|
343
|
+
let htmlCommitted = false;
|
|
344
|
+
try {
|
|
345
|
+
if (iterations) { await fs.rename(stagedHtml, source); htmlCommitted = true; }
|
|
346
|
+
if (output) await fs.rename(stagedPdf, output);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (htmlCommitted) {
|
|
349
|
+
await fs.rename(backupHtml, source);
|
|
350
|
+
}
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
result.checks.auto_heal.committed = iterations > 0;
|
|
354
|
+
result.checks.output = { status: 'PASS', committed: !!output, path: output,
|
|
355
|
+
pdfSha256: createHash('sha256').update(attempt.bytes).digest('hex'),
|
|
356
|
+
htmlSha256: createHash('sha256').update(iterations ? Buffer.from(candidate, 'utf8') : originalBytes).digest('hex') };
|
|
357
|
+
return finish(result);
|
|
358
|
+
} catch (error) { failure(result, 'output', error.message, 'FAIL'); return finish(result); }
|
|
359
|
+
} catch (error) {
|
|
360
|
+
failure(initial, phase, error.message, error.code === 'ENOENT' && phase === 'input' ? 'FAIL' : 'UNVERIFIED');
|
|
361
|
+
return finish(initial);
|
|
362
|
+
} finally {
|
|
363
|
+
if (browser) await browser.close().catch(() => {});
|
|
364
|
+
for (const file of [stagedHtml, stagedPdf, backupHtml]) if (file) await fs.unlink(file).catch(() => {});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function cli(args = process.argv.slice(2), defaults = {}) {
|
|
369
|
+
if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
|
|
370
|
+
process.stdout.write('Usage: browser-engine.js <resume.html> [--output <resume.pdf>] [--expected-pages 1|2] [--auto-heal]\n browser-engine.js --check-runtime\n');
|
|
371
|
+
process.exitCode = 0;
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (args.length === 1 && args[0] === '--check-runtime') {
|
|
375
|
+
const result = await checkRuntime();
|
|
376
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
377
|
+
process.exitCode = result.status === 'READY' ? 0 : 2;
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
let html = 'workspace/resume.html';
|
|
381
|
+
const options = { ...defaults };
|
|
382
|
+
const positional = [];
|
|
383
|
+
let bad;
|
|
384
|
+
for (let i = 0; i < args.length; i++) {
|
|
385
|
+
const arg = args[i];
|
|
386
|
+
if (['--html', '--input', '-i'].includes(arg)) html = args[++i];
|
|
387
|
+
else if (['--output', '-o', '--output-pdf'].includes(arg)) {
|
|
388
|
+
options.outputPdf = args[++i];
|
|
389
|
+
if (!options.outputPdf || options.outputPdf.startsWith('-')) bad = 'Missing PDF output path';
|
|
390
|
+
}
|
|
391
|
+
else if (['--expected-pages', '-p'].includes(arg)) options.expectedPages = Number(args[++i]);
|
|
392
|
+
else if (arg === '--auto-heal') options.autoHeal = true;
|
|
393
|
+
else if (['--json', '--quiet', '-q'].includes(arg)) continue;
|
|
394
|
+
else if (arg.startsWith('-')) bad = `Unknown argument: ${arg}`;
|
|
395
|
+
else positional.push(arg);
|
|
396
|
+
}
|
|
397
|
+
if (positional.length > 2) bad = 'Too many positional arguments';
|
|
398
|
+
html = positional[0] || html;
|
|
399
|
+
if (positional[1]) options.outputPdf = positional[1];
|
|
400
|
+
let result;
|
|
401
|
+
if (bad || !html) { result = resultFor(html || '.', options.expectedPages || 1); failure(result, 'input', bad || 'Missing HTML path'); finish(result); }
|
|
402
|
+
else result = await run(html, options);
|
|
403
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
404
|
+
process.exitCode = result.status === 'PASS' ? 0 : result.status === 'FAIL' ? 1 : 2;
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
module.exports = { run, cli, reduceTokens, missingText, systemBrowsers };
|
|
408
|
+
if (require.main === module) cli();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Standard-library bridge to the single Node rendering/validation engine."""
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
ENGINE = Path(__file__).resolve().with_name("browser-engine.js")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run(html, *, expected_pages=1, auto_heal=False, output_pdf=None):
|
|
10
|
+
command = ["node", str(ENGINE), str(Path(html).resolve()),
|
|
11
|
+
"--expected-pages", str(expected_pages)]
|
|
12
|
+
if auto_heal:
|
|
13
|
+
command.append("--auto-heal")
|
|
14
|
+
if output_pdf is not None:
|
|
15
|
+
command.extend(["--output", str(Path(output_pdf).resolve())])
|
|
16
|
+
try:
|
|
17
|
+
# The engine bounds browser operations and owns publication/cleanup.
|
|
18
|
+
proc = subprocess.run(command, capture_output=True, text=True)
|
|
19
|
+
result = json.loads(proc.stdout)
|
|
20
|
+
if not isinstance(result, dict):
|
|
21
|
+
raise ValueError('Engine result must be an object')
|
|
22
|
+
expected_exit = {"PASS": 0, "FAIL": 1, "UNVERIFIED": 2}.get(result.get("status"))
|
|
23
|
+
if (expected_exit is None or proc.returncode != expected_exit
|
|
24
|
+
or not isinstance(result.get("errors"), list)
|
|
25
|
+
or not isinstance(result.get("warnings"), list)
|
|
26
|
+
or not isinstance(result.get("checks"), dict)
|
|
27
|
+
or (result["status"] == "PASS" and (result["errors"] or any(
|
|
28
|
+
not isinstance(result["checks"].get(key), dict) or result["checks"][key].get("status") != "PASS"
|
|
29
|
+
for key in ('input', 'fonts', 'dom', 'pdf', 'text', 'output'))))):
|
|
30
|
+
raise ValueError("Invalid engine result or exit-code mismatch")
|
|
31
|
+
return result
|
|
32
|
+
except (OSError, ValueError) as error:
|
|
33
|
+
return {"status": "UNVERIFIED", "file": str(Path(html).resolve()),
|
|
34
|
+
"expectedPages": expected_pages, "errors": [str(error)],
|
|
35
|
+
"warnings": [], "checks": {"engine": {"status": "UNVERIFIED"}}}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def exit_code(result):
|
|
39
|
+
return {"PASS": 0, "FAIL": 1, "UNVERIFIED": 2}[result["status"]]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
// tsc does not copy JS. Resolve the shipped source engine from source or dist.
|
|
4
|
+
let root = __dirname;
|
|
5
|
+
while (!fs.existsSync(path.join(root, 'package.json'))) {
|
|
6
|
+
const parent = path.dirname(root);
|
|
7
|
+
if (parent === root) throw new Error('Cannot locate browser engine project root');
|
|
8
|
+
root = parent;
|
|
9
|
+
}
|
|
10
|
+
const engine: typeof import('./browser-engine') = require(path.join(root, 'scripts/rendering/browser-engine.js'));
|
|
11
|
+
export = engine;
|