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.
Files changed (117) hide show
  1. package/AGENT.md +276 -0
  2. package/ARCHITECTURE.md +220 -0
  3. package/README.md +46 -63
  4. package/README.zh-CN.md +47 -66
  5. package/SKILL.md +38 -135
  6. package/agents/claude/knowme-careerforge.md +2 -17
  7. package/agents/codex/knowme-careerforge.yaml +2 -20
  8. package/agents/cursor/knowme-careerforge.mdc +4 -13
  9. package/agents/gemini/knowme-careerforge.json +1 -1
  10. package/agents/opencode/skill.yaml +1 -1
  11. package/agents/windsurf/knowme-careerforge.rules +2 -12
  12. package/dist/cli/src/commands/init.js +51 -32
  13. package/dist/cli/src/index.js +25 -21
  14. package/dist/scripts/rendering/engine-loader.d.ts +2 -0
  15. package/dist/scripts/rendering/engine-loader.js +46 -0
  16. package/dist/scripts/rendering/render-pdf.d.ts +3 -8
  17. package/dist/scripts/rendering/render-pdf.js +7 -103
  18. package/dist/scripts/rendering/runtime.d.ts +9 -0
  19. package/dist/scripts/rendering/runtime.js +63 -0
  20. package/dist/scripts/validation/validate-ats.d.ts +2 -22
  21. package/dist/scripts/validation/validate-ats.js +6 -168
  22. package/dist/scripts/validation/validate-layout.d.ts +2 -23
  23. package/dist/scripts/validation/validate-layout.js +5 -159
  24. package/package.json +15 -4
  25. package/references/01-evidence-mining.md +35 -0
  26. package/references/02-career-goal.md +40 -0
  27. package/references/03-jd-analysis.md +32 -0
  28. package/references/04-template-selection.md +30 -0
  29. package/references/05-html-canvas-tokens.md +12 -0
  30. package/references/06-qa-and-rendering.md +27 -0
  31. package/references/07-artifact-contract.md +83 -0
  32. package/scripts/Agent.md +9 -136
  33. package/scripts/build/build-gallery.py +66 -56
  34. package/scripts/build/run-all-tests.py +1 -0
  35. package/scripts/build/sync-version.py +27 -9
  36. package/scripts/contracts/profile.py +101 -0
  37. package/scripts/evidence/extract-evidence.py +109 -62
  38. package/scripts/pipeline/forge.py +276 -136
  39. package/scripts/rendering/README.md +65 -0
  40. package/scripts/rendering/browser-engine.d.ts +15 -0
  41. package/scripts/rendering/browser-engine.js +408 -0
  42. package/scripts/rendering/browser_engine.py +39 -0
  43. package/scripts/rendering/engine-loader.ts +11 -0
  44. package/scripts/rendering/render-pdf.py +26 -151
  45. package/scripts/rendering/render-pdf.ts +7 -83
  46. package/scripts/template/instantiate-resume.py +266 -173
  47. package/scripts/template/search-template.py +89 -3
  48. package/scripts/validation/measure-dom.js +5 -0
  49. package/scripts/validation/validate-ats.ts +6 -174
  50. package/scripts/validation/validate-layout.ts +5 -159
  51. package/scripts/validation/validate-resume.py +23 -134
  52. package/skill.json +7 -6
  53. package/src/knowledge/index.json +391 -3
  54. package/src/knowledge/resume-schema.json +377 -71
  55. package/src/knowledge/templates.json +389 -1
  56. package/src/references/01-evidence-mining.md +24 -30
  57. package/src/references/02-career-goal.md +4 -2
  58. package/src/references/03-jd-analysis.md +5 -17
  59. package/src/references/04-template-selection.md +20 -33
  60. package/src/references/05-html-canvas-tokens.md +6 -45
  61. package/src/references/06-qa-and-rendering.md +16 -43
  62. package/src/references/07-artifact-contract.md +83 -0
  63. package/src/templates/Agent.md +11 -0
  64. package/src/templates/academic-research/README.md +25 -0
  65. package/src/templates/academic-research/canvas.html +21 -0
  66. package/src/templates/academic-research/metadata.json +38 -0
  67. package/src/templates/academic-research/sample-profile.json +86 -0
  68. package/src/templates/academic-research/style.css +213 -0
  69. package/src/templates/classic/README.md +9 -0
  70. package/src/templates/classic/canvas.html +21 -0
  71. package/src/templates/classic/metadata.json +3 -0
  72. package/src/templates/classic/sample-profile.json +86 -0
  73. package/src/templates/classic/style.css +14 -4
  74. package/src/templates/common/base.css +32 -0
  75. package/src/templates/common/canvas-bindings.css +41 -0
  76. package/src/templates/common/resume-contract.md +8 -0
  77. package/src/templates/compact-dense/README.md +25 -0
  78. package/src/templates/compact-dense/canvas.html +14 -0
  79. package/src/templates/compact-dense/metadata.json +38 -0
  80. package/src/templates/compact-dense/sample-profile.json +86 -0
  81. package/src/templates/compact-dense/style.css +218 -0
  82. package/src/templates/creative-tech/README.md +26 -0
  83. package/src/templates/creative-tech/canvas.html +15 -0
  84. package/src/templates/creative-tech/metadata.json +39 -0
  85. package/src/templates/creative-tech/sample-profile.json +86 -0
  86. package/src/templates/creative-tech/style.css +257 -0
  87. package/src/templates/data-analyst/README.md +26 -0
  88. package/src/templates/data-analyst/canvas.html +15 -0
  89. package/src/templates/data-analyst/metadata.json +39 -0
  90. package/src/templates/data-analyst/sample-profile.json +86 -0
  91. package/src/templates/data-analyst/style.css +285 -0
  92. package/src/templates/executive/README.md +9 -0
  93. package/src/templates/executive/canvas.html +15 -0
  94. package/src/templates/executive/sample-profile.json +86 -0
  95. package/src/templates/executive/style.css +58 -6
  96. package/src/templates/international-flow/README.md +26 -0
  97. package/src/templates/international-flow/canvas.html +21 -0
  98. package/src/templates/international-flow/metadata.json +37 -0
  99. package/src/templates/international-flow/sample-profile.json +86 -0
  100. package/src/templates/international-flow/style.css +208 -0
  101. package/src/templates/minimal/README.md +12 -1
  102. package/src/templates/minimal/canvas.html +21 -0
  103. package/src/templates/minimal/sample-profile.json +86 -0
  104. package/src/templates/minimal/style.css +24 -0
  105. package/src/templates/modern/README.md +9 -0
  106. package/src/templates/modern/canvas.html +15 -0
  107. package/src/templates/modern/sample-profile.json +86 -0
  108. package/src/templates/modern/style.css +48 -1
  109. package/src/templates/startup-generalist/README.md +25 -0
  110. package/src/templates/startup-generalist/canvas.html +21 -0
  111. package/src/templates/startup-generalist/metadata.json +37 -0
  112. package/src/templates/startup-generalist/sample-profile.json +86 -0
  113. package/src/templates/startup-generalist/style.css +237 -0
  114. package/src/templates/classic/template.html +0 -133
  115. package/src/templates/executive/template.html +0 -101
  116. package/src/templates/minimal/template.html +0 -114
  117. 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;