knowme-careerforge-skill 0.0.4 → 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.
Files changed (112) hide show
  1. package/AGENT.md +276 -0
  2. package/ARCHITECTURE.md +216 -0
  3. package/README.md +34 -54
  4. package/README.zh-CN.md +35 -57
  5. package/SKILL.md +28 -147
  6. package/agents/claude/knowme-careerforge.md +2 -17
  7. package/agents/codex/knowme-careerforge.yaml +3 -5
  8. package/agents/cursor/knowme-careerforge.mdc +4 -13
  9. package/agents/opencode/skill.yaml +1 -1
  10. package/agents/windsurf/knowme-careerforge.rules +2 -12
  11. package/dist/cli/src/index.js +25 -21
  12. package/dist/scripts/rendering/engine-loader.d.ts +2 -0
  13. package/dist/scripts/rendering/engine-loader.js +46 -0
  14. package/dist/scripts/rendering/render-pdf.d.ts +3 -8
  15. package/dist/scripts/rendering/render-pdf.js +7 -103
  16. package/dist/scripts/rendering/runtime.d.ts +9 -0
  17. package/dist/scripts/rendering/runtime.js +63 -0
  18. package/dist/scripts/validation/validate-ats.d.ts +2 -22
  19. package/dist/scripts/validation/validate-ats.js +6 -168
  20. package/dist/scripts/validation/validate-layout.d.ts +2 -23
  21. package/dist/scripts/validation/validate-layout.js +5 -159
  22. package/package.json +15 -4
  23. package/references/01-evidence-mining.md +5 -0
  24. package/references/02-career-goal.md +38 -0
  25. package/references/03-jd-analysis.md +44 -0
  26. package/references/04-template-selection.md +43 -0
  27. package/references/05-html-canvas-tokens.md +12 -0
  28. package/references/06-qa-and-rendering.md +14 -0
  29. package/references/07-artifact-contract.md +64 -0
  30. package/scripts/Agent.md +9 -136
  31. package/scripts/build/build-gallery.py +66 -56
  32. package/scripts/build/run-all-tests.py +1 -0
  33. package/scripts/build/sync-version.py +27 -9
  34. package/scripts/contracts/profile.py +101 -0
  35. package/scripts/evidence/extract-evidence.py +109 -62
  36. package/scripts/pipeline/forge.py +266 -136
  37. package/scripts/rendering/README.md +65 -0
  38. package/scripts/rendering/browser-engine.d.ts +15 -0
  39. package/scripts/rendering/browser-engine.js +366 -0
  40. package/scripts/rendering/browser_engine.py +39 -0
  41. package/scripts/rendering/engine-loader.ts +11 -0
  42. package/scripts/rendering/render-pdf.py +26 -151
  43. package/scripts/rendering/render-pdf.ts +7 -83
  44. package/scripts/template/instantiate-resume.py +266 -173
  45. package/scripts/template/search-template.py +80 -3
  46. package/scripts/validation/measure-dom.js +5 -0
  47. package/scripts/validation/validate-ats.ts +6 -174
  48. package/scripts/validation/validate-layout.ts +5 -159
  49. package/scripts/validation/validate-resume.py +23 -134
  50. package/skill.json +1 -1
  51. package/src/knowledge/index.json +391 -3
  52. package/src/knowledge/resume-schema.json +377 -71
  53. package/src/knowledge/templates.json +389 -1
  54. package/src/references/01-evidence-mining.md +3 -39
  55. package/src/references/05-html-canvas-tokens.md +6 -45
  56. package/src/references/06-qa-and-rendering.md +7 -47
  57. package/src/references/07-artifact-contract.md +64 -0
  58. package/src/templates/Agent.md +11 -0
  59. package/src/templates/academic-research/README.md +25 -0
  60. package/src/templates/academic-research/canvas.html +21 -0
  61. package/src/templates/academic-research/metadata.json +38 -0
  62. package/src/templates/academic-research/sample-profile.json +86 -0
  63. package/src/templates/academic-research/style.css +213 -0
  64. package/src/templates/classic/README.md +9 -0
  65. package/src/templates/classic/canvas.html +21 -0
  66. package/src/templates/classic/metadata.json +3 -0
  67. package/src/templates/classic/sample-profile.json +86 -0
  68. package/src/templates/classic/style.css +14 -4
  69. package/src/templates/common/base.css +32 -0
  70. package/src/templates/common/canvas-bindings.css +41 -0
  71. package/src/templates/common/resume-contract.md +8 -0
  72. package/src/templates/compact-dense/README.md +25 -0
  73. package/src/templates/compact-dense/canvas.html +14 -0
  74. package/src/templates/compact-dense/metadata.json +38 -0
  75. package/src/templates/compact-dense/sample-profile.json +86 -0
  76. package/src/templates/compact-dense/style.css +218 -0
  77. package/src/templates/creative-tech/README.md +26 -0
  78. package/src/templates/creative-tech/canvas.html +15 -0
  79. package/src/templates/creative-tech/metadata.json +39 -0
  80. package/src/templates/creative-tech/sample-profile.json +86 -0
  81. package/src/templates/creative-tech/style.css +257 -0
  82. package/src/templates/data-analyst/README.md +26 -0
  83. package/src/templates/data-analyst/canvas.html +15 -0
  84. package/src/templates/data-analyst/metadata.json +39 -0
  85. package/src/templates/data-analyst/sample-profile.json +86 -0
  86. package/src/templates/data-analyst/style.css +285 -0
  87. package/src/templates/executive/README.md +9 -0
  88. package/src/templates/executive/canvas.html +15 -0
  89. package/src/templates/executive/sample-profile.json +86 -0
  90. package/src/templates/executive/style.css +58 -6
  91. package/src/templates/international-flow/README.md +26 -0
  92. package/src/templates/international-flow/canvas.html +21 -0
  93. package/src/templates/international-flow/metadata.json +37 -0
  94. package/src/templates/international-flow/sample-profile.json +86 -0
  95. package/src/templates/international-flow/style.css +208 -0
  96. package/src/templates/minimal/README.md +9 -0
  97. package/src/templates/minimal/canvas.html +21 -0
  98. package/src/templates/minimal/sample-profile.json +86 -0
  99. package/src/templates/minimal/style.css +24 -0
  100. package/src/templates/modern/README.md +9 -0
  101. package/src/templates/modern/canvas.html +15 -0
  102. package/src/templates/modern/sample-profile.json +86 -0
  103. package/src/templates/modern/style.css +48 -1
  104. package/src/templates/startup-generalist/README.md +25 -0
  105. package/src/templates/startup-generalist/canvas.html +21 -0
  106. package/src/templates/startup-generalist/metadata.json +37 -0
  107. package/src/templates/startup-generalist/sample-profile.json +86 -0
  108. package/src/templates/startup-generalist/style.css +237 -0
  109. package/src/templates/classic/template.html +0 -133
  110. package/src/templates/executive/template.html +0 -101
  111. package/src/templates/minimal/template.html +0 -114
  112. package/src/templates/modern/template.html +0 -139
@@ -0,0 +1,366 @@
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
+ async function systemBrowsers() {
152
+ const home = require('node:os').homedir();
153
+ const candidates = [
154
+ ...['/Applications', path.join(home, 'Applications')].flatMap(base => [
155
+ 'Google Chrome.app/Contents/MacOS/Google Chrome', 'Chromium.app/Contents/MacOS/Chromium',
156
+ 'Microsoft Edge.app/Contents/MacOS/Microsoft Edge', 'Brave Browser.app/Contents/MacOS/Brave Browser'
157
+ ].map(name => path.join(base, name))),
158
+ ...[process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].filter(Boolean).flatMap(base => [
159
+ 'Google/Chrome/Application/chrome.exe', 'Microsoft/Edge/Application/msedge.exe', 'BraveSoftware/Brave-Browser/Application/brave.exe'
160
+ ].map(name => path.join(base, name))),
161
+ ...(process.env.PATH || '').split(path.delimiter).filter(Boolean).flatMap(base =>
162
+ ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge', 'brave-browser'].map(name => path.join(base, name)))
163
+ ];
164
+ const found = [];
165
+ for (const candidate of new Set(candidates)) {
166
+ try { await fs.access(candidate, require('node:fs').constants.X_OK); found.push(candidate); } catch {}
167
+ }
168
+ return found;
169
+ }
170
+ async function launchBrowser(pw) {
171
+ const override = process.env.CHROME_BIN || process.env.BROWSER_PATH;
172
+ if (override) return pw.chromium.launch({ headless: true, executablePath: override });
173
+ const failures = [];
174
+ for (const executablePath of [undefined, ...await systemBrowsers()]) {
175
+ try { return await pw.chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }); }
176
+ catch (error) { failures.push(`${executablePath || 'bundled Chromium'}: ${error.message}`); }
177
+ }
178
+ throw new Error(`No usable browser: ${failures.join('\n')}`);
179
+ }
180
+
181
+ async function inspect(page, htmlFile, expectedPages, deps) {
182
+ const result = resultFor(htmlFile, expectedPages);
183
+ result.checks.input = { status: 'PASS' };
184
+ let phase = 'fonts';
185
+ const resourceErrors = [];
186
+ const onFailed = request => resourceErrors.push(request.url());
187
+ const onResponse = response => { if (response.status() >= 400) resourceErrors.push(response.url()); };
188
+ page.on('requestfailed', onFailed);
189
+ page.on('response', onResponse);
190
+ try {
191
+ await page.emulateMedia({ media: 'print' });
192
+ await page.goto(pathToFileURL(htmlFile).href, { waitUntil: 'networkidle', timeout: 30000 });
193
+ await page.evaluate(async () => {
194
+ await Promise.race([document.fonts.ready, new Promise((_, reject) => setTimeout(() => reject(new Error('Font wait timed out')), 15000))]);
195
+ if ([...document.fonts].some(f => f.status === 'error')) throw new Error('A font failed to load');
196
+ await Promise.all([...document.images].map(img => img.decode()));
197
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
198
+ });
199
+ if (resourceErrors.length) throw new Error(`Resources failed to load: ${resourceErrors.join(', ')}`);
200
+ result.checks.fonts = { status: 'PASS' };
201
+ phase = 'dom';
202
+ const dom = await page.evaluate(inspectDOM);
203
+ result.checks.dom = { status: 'PASS', ...dom };
204
+ if (dom.totalPagesFound < 1 || dom.totalPagesFound > expectedPages) failure(result, 'dom', `Require 1–${expectedPages} explicit .resume-page containers, found ${dom.totalPagesFound}`);
205
+ if (dom.issues.length) failure(result, 'dom', `${dom.issues.length} boundary/clipping violations`);
206
+ if (dom.unsupported.length && !dom.issues.length) failure(result, 'dom', 'Unsupported clipping', 'UNVERIFIED');
207
+ phase = 'pdf';
208
+ const bytes = await page.pdf({ format: 'A4', preferCSSPageSize: true, printBackground: true,
209
+ displayHeaderFooter: false, margin: { top: 0, right: 0, bottom: 0, left: 0 } });
210
+ const pdf = await deps.PDFDocument.load(bytes);
211
+ const sizes = pdf.getPages().map((p, i) => ({ pageIndex: i + 1, ...p.getSize(), cropBox: p.getCropBox(), rotation: p.getRotation().angle }));
212
+ result.checks.pdf = { status: 'PASS', pageCount: sizes.length, pages: sizes };
213
+ if (sizes.length < 1 || sizes.length > expectedPages) failure(result, 'pdf', `Page limit ${expectedPages}, generated ${sizes.length}`);
214
+ 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');
215
+ 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))
216
+ failure(result, 'pdf', 'Every PDF page must be portrait A4 (1pt tolerance)');
217
+ phase = 'text';
218
+ const task = deps.pdfjs.getDocument({ data: new Uint8Array(bytes), useSystemFonts: true, verbosity: 0 });
219
+ try {
220
+ const doc = await task.promise;
221
+ const pages = [];
222
+ for (let n = 1; n <= doc.numPages; n++) {
223
+ const content = await (await doc.getPage(n)).getTextContent();
224
+ const text = content.items.map(item => item.str || '').join(' ');
225
+ const expected = dom.pages[n - 1]?.text || '';
226
+ const missing = missingText(expected, text);
227
+ const missingFragments = (dom.pages[n - 1]?.textFragments || [])
228
+ .map(normalized).filter(fragment => fragment && !normalized(text).includes(fragment));
229
+ const codepoints = value => [...new Set([...value])].map(character => ({
230
+ character, codepoint: 'U+' + character.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')
231
+ }));
232
+ const extractedRadicals = [...text].filter(character => {
233
+ const cp = character.codePointAt(0); return cp >= 0x2E80 && cp <= 0x2FDF;
234
+ }).join('');
235
+ pages.push({ pageIndex: n, text, expectedCharacters: normalized(expected).length, missing, missingFragments,
236
+ missingCodepoints: codepoints(missing), extractedRadicals: codepoints(extractedRadicals) });
237
+ if (missing && extractedRadicals)
238
+ 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.`);
239
+ }
240
+ result.checks.text = { status: 'PASS', pages };
241
+ if (doc.numPages !== sizes.length || pages.some(p => !normalized(p.text) || !p.expectedCharacters || p.missing || p.missingFragments.length || p.text.includes('\ufffd')))
242
+ failure(result, 'text', 'Blank, missing, undecodable or mismatched per-page PDF text');
243
+ } finally { await task.destroy(); }
244
+ result.checks.output = { status: 'PASS', committed: false };
245
+ return { result: finish(result), bytes };
246
+ } catch (error) {
247
+ failure(result, phase, error.message, 'UNVERIFIED');
248
+ return { result: finish(result) };
249
+ } finally {
250
+ page.off('requestfailed', onFailed);
251
+ page.off('response', onResponse);
252
+ }
253
+ }
254
+
255
+ async function run(html, { expectedPages = 1, autoHeal = false, outputPdf } = {}) {
256
+ const initial = resultFor(html, expectedPages);
257
+ const source = initial.file;
258
+ let browser, stagedHtml, stagedPdf, backupHtml;
259
+ let phase = 'input';
260
+ try {
261
+ if (![1, 2].includes(expectedPages)) {
262
+ failure(initial, 'input', 'expectedPages must be the page limit 1 or 2'); return finish(initial);
263
+ }
264
+ const originalBytes = await fs.readFile(source);
265
+ const original = originalBytes.toString('utf8');
266
+ const output = outputPdf ? path.resolve(outputPdf) : null;
267
+ if (output && (output === source || await fs.realpath(output).catch(() => output) === await fs.realpath(source))) {
268
+ failure(initial, 'input', 'PDF output must differ from HTML source'); return finish(initial);
269
+ }
270
+ initial.checks.input = { status: 'PASS' };
271
+ phase = 'fonts';
272
+ const deps = await dependencies();
273
+ browser = await launchBrowser(deps.pw);
274
+ const page = await browser.newPage({ viewport: { width: 794, height: 1123 } });
275
+ let attempt = await inspect(page, source, expectedPages, deps);
276
+ let candidate = original;
277
+ const tunedTokens = {};
278
+ let iterations = 0;
279
+ if (autoHeal && attempt.result.status === 'FAIL' && attempt.result.checks.dom.issues?.length) {
280
+ stagedHtml = path.join(path.dirname(source), `.${path.basename(source)}.${randomUUID()}.html`);
281
+ for (const targets of ladder) {
282
+ const reduced = reduceTokens(candidate, targets);
283
+ if (reduced.content === candidate) continue;
284
+ candidate = reduced.content;
285
+ Object.assign(tunedTokens, reduced.tuned);
286
+ await fs.writeFile(stagedHtml, candidate, { mode: (await fs.stat(source)).mode });
287
+ iterations++;
288
+ attempt = await inspect(page, stagedHtml, expectedPages, deps);
289
+ if (attempt.result.status !== 'FAIL') break;
290
+ }
291
+ }
292
+ await browser.close(); browser = null;
293
+ const result = attempt.result;
294
+ result.file = source;
295
+ result.checks.auto_heal = { status: result.status, requested: autoHeal, healed: result.status === 'PASS', iterations,
296
+ tunedTokens: result.status === 'PASS' ? tunedTokens : {}, committed: false };
297
+ if (result.status !== 'PASS') return result;
298
+ phase = 'output';
299
+ // All validation and browser shutdown precede publication. Each file is replaced
300
+ // by a same-directory rename; roll back HTML if the PDF rename fails.
301
+ try {
302
+ if (!(await fs.readFile(source)).equals(originalBytes)) throw new Error('HTML changed during validation');
303
+ if (output) {
304
+ await fs.mkdir(path.dirname(output), { recursive: true });
305
+ stagedPdf = path.join(path.dirname(output), `.${path.basename(output)}.${randomUUID()}.tmp`);
306
+ await fs.writeFile(stagedPdf, attempt.bytes, { flag: 'wx' });
307
+ }
308
+ if (iterations) {
309
+ backupHtml = path.join(path.dirname(source), `.${path.basename(source)}.${randomUUID()}.backup`);
310
+ await fs.copyFile(source, backupHtml);
311
+ }
312
+ let htmlCommitted = false;
313
+ try {
314
+ if (iterations) { await fs.rename(stagedHtml, source); htmlCommitted = true; }
315
+ if (output) await fs.rename(stagedPdf, output);
316
+ } catch (error) {
317
+ if (htmlCommitted) {
318
+ await fs.rename(backupHtml, source);
319
+ }
320
+ throw error;
321
+ }
322
+ result.checks.auto_heal.committed = iterations > 0;
323
+ result.checks.output = { status: 'PASS', committed: !!output, path: output,
324
+ pdfSha256: createHash('sha256').update(attempt.bytes).digest('hex'),
325
+ htmlSha256: createHash('sha256').update(iterations ? Buffer.from(candidate, 'utf8') : originalBytes).digest('hex') };
326
+ return finish(result);
327
+ } catch (error) { failure(result, 'output', error.message, 'FAIL'); return finish(result); }
328
+ } catch (error) {
329
+ failure(initial, phase, error.message, error.code === 'ENOENT' && phase === 'input' ? 'FAIL' : 'UNVERIFIED');
330
+ return finish(initial);
331
+ } finally {
332
+ if (browser) await browser.close().catch(() => {});
333
+ for (const file of [stagedHtml, stagedPdf, backupHtml]) if (file) await fs.unlink(file).catch(() => {});
334
+ }
335
+ }
336
+
337
+ async function cli(args = process.argv.slice(2), defaults = {}) {
338
+ let html = 'workspace/resume.html';
339
+ const options = { ...defaults };
340
+ const positional = [];
341
+ let bad;
342
+ for (let i = 0; i < args.length; i++) {
343
+ const arg = args[i];
344
+ if (['--html', '--input', '-i'].includes(arg)) html = args[++i];
345
+ else if (['--output', '-o', '--output-pdf'].includes(arg)) {
346
+ options.outputPdf = args[++i];
347
+ if (!options.outputPdf || options.outputPdf.startsWith('-')) bad = 'Missing PDF output path';
348
+ }
349
+ else if (['--expected-pages', '-p'].includes(arg)) options.expectedPages = Number(args[++i]);
350
+ else if (arg === '--auto-heal') options.autoHeal = true;
351
+ else if (['--json', '--quiet', '-q'].includes(arg)) continue;
352
+ else if (arg.startsWith('-')) bad = `Unknown argument: ${arg}`;
353
+ else positional.push(arg);
354
+ }
355
+ if (positional.length > 2) bad = 'Too many positional arguments';
356
+ html = positional[0] || html;
357
+ if (positional[1]) options.outputPdf = positional[1];
358
+ let result;
359
+ if (bad || !html) { result = resultFor(html || '.', options.expectedPages || 1); failure(result, 'input', bad || 'Missing HTML path'); finish(result); }
360
+ else result = await run(html, options);
361
+ process.stdout.write(JSON.stringify(result) + '\n');
362
+ process.exitCode = result.status === 'PASS' ? 0 : result.status === 'FAIL' ? 1 : 2;
363
+ return result;
364
+ }
365
+ module.exports = { run, cli, reduceTokens, missingText, systemBrowsers };
366
+ 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;
@@ -1,162 +1,37 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- KnowMe CareerForge — Multi-Strategy Deterministic PDF Renderer
4
- 多策略自愈式 A4 PDF 渲染器:自动嗅探 Playwright、系统级 Chromium/Chrome/Edge/Brave 浏览器并实现零配置确定性导出。
5
- """
6
-
7
- import sys, os, subprocess, platform, shutil, argparse
2
+ """Render and verify a fresh PDF through browser-engine.js before publication."""
3
+ import argparse
4
+ import json
5
+ import sys
8
6
  from pathlib import Path
9
- from typing import Optional, List
10
-
11
- def get_project_root() -> Path:
12
- curr = Path(__file__).resolve().parent
13
- for _ in range(5):
14
- if (curr / "SKILL.md").exists() or (curr / "package.json").exists():
15
- return curr
16
- curr = curr.parent
17
- return Path.cwd()
18
-
19
- def find_system_browser() -> Optional[str]:
20
- system = platform.system()
21
-
22
- custom_bin = os.environ.get("CHROME_BIN") or os.environ.get("BROWSER_PATH")
23
- if custom_bin and Path(custom_bin).exists():
24
- return custom_bin
25
-
26
- candidates: List[str] = []
27
-
28
- if system == "Darwin": # macOS
29
- candidates = [
30
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
31
- "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
32
- "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
33
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
34
- str(Path.home() / "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
35
- str(Path.home() / "Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
36
- ]
37
- elif system == "Linux":
38
- candidates = [
39
- "google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
40
- "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable",
41
- "/usr/bin/chromium", "/usr/bin/chromium-browser",
42
- "/snap/bin/chromium"
43
- ]
44
- elif system == "Windows":
45
- prog_files = os.environ.get("PROGRAMFILES", r"C:\Program Files")
46
- prog_files_x86 = os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")
47
- local_app = os.environ.get("LOCALAPPDATA", "")
48
-
49
- candidates = [
50
- os.path.join(prog_files, r"Google\Chrome\Application\chrome.exe"),
51
- os.path.join(prog_files_x86, r"Google\Chrome\Application\chrome.exe"),
52
- os.path.join(prog_files_x86, r"Microsoft\Edge\Application\msedge.exe"),
53
- os.path.join(prog_files, r"Microsoft\Edge\Application\msedge.exe"),
54
- os.path.join(local_app, r"Google\Chrome\Application\chrome.exe"),
55
- os.path.join(local_app, r"Microsoft\Edge\Application\msedge.exe"),
56
- os.path.join(local_app, r"BraveSoftware\Brave-Browser\Application\brave.exe"),
57
- ]
58
-
59
- for candidate in candidates:
60
- if candidate.startswith("/") or ":" in candidate:
61
- if Path(candidate).exists():
62
- return candidate
63
- else:
64
- resolved = shutil.which(candidate)
65
- if resolved:
66
- return resolved
67
-
68
- return None
69
-
70
- def render_via_playwright(html_path: Path, pdf_path: Path) -> bool:
71
- root = get_project_root()
72
- script_ts = root / "scripts" / "rendering" / "render-pdf.ts"
73
- if not script_ts.exists():
74
- script_ts = root / "scripts" / "render-pdf.ts"
7
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
8
+ from browser_engine import run, exit_code
75
9
 
76
- if script_ts.exists() and shutil.which("npx"):
77
- try:
78
- cmd = ["npx", "ts-node", str(script_ts), "--input", str(html_path), "--output", str(pdf_path)]
79
- res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=30)
80
- if res.returncode == 0 and pdf_path.exists():
81
- return True
82
- except Exception:
83
- pass
84
- return False
85
10
 
86
- def render_via_browser(browser_bin: str, html_path: Path, pdf_path: Path) -> bool:
87
- html_url = html_path.resolve().as_uri()
88
- cmd = [
89
- browser_bin,
90
- "--headless=new",
91
- "--disable-gpu",
92
- "--no-first-run",
93
- "--no-default-browser-check",
94
- "--disable-background-networking",
95
- "--no-pdf-header-footer",
96
- "--run-all-compositor-stages-before-draw",
97
- f"--print-to-pdf={pdf_path.resolve()}",
98
- html_url
99
- ]
100
- try:
101
- res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=25)
102
- return pdf_path.exists() and pdf_path.stat().st_size > 0
103
- except Exception as e:
104
- print(f"[!] Browser render failed: {e}")
105
- return False
11
+ def render_pdf(html_path_str, pdf_path_str, quiet=False, expected_pages=1, auto_heal=False):
12
+ result = run(html_path_str, expected_pages=expected_pages,
13
+ auto_heal=auto_heal, output_pdf=pdf_path_str)
14
+ if result["status"] != "PASS":
15
+ raise RuntimeError(json.dumps(result, ensure_ascii=False))
16
+ return Path(pdf_path_str).resolve()
106
17
 
107
- def render_pdf(html_path_str: str, pdf_path_str: str, quiet: bool = False) -> Path:
108
- html_path = Path(html_path_str).resolve()
109
- pdf_path = Path(pdf_path_str).resolve()
110
-
111
- if not html_path.exists():
112
- raise FileNotFoundError(f"Input HTML file does not exist: {html_path}")
113
-
114
- pdf_path.parent.mkdir(parents=True, exist_ok=True)
115
-
116
- if not quiet:
117
- print("==============================================================")
118
- print(" KnowMe CareerForge — Multi-Strategy PDF Renderer")
119
- print(f" Source Canvas : {html_path}")
120
- print(f" Target PDF : {pdf_path}")
121
- print("==============================================================")
122
-
123
- # Strategy 1: Playwright Headless
124
- if not quiet:
125
- print("[*] Strategy 1: Trying Playwright Headless Engine...")
126
- if render_via_playwright(html_path, pdf_path):
127
- if not quiet:
128
- print(f"[✓] Render Succeeded via Playwright -> {pdf_path}")
129
- return pdf_path
130
-
131
- # Strategy 2: System Installed Chromium/Chrome/Edge/Brave
132
- browser_bin = find_system_browser()
133
- if browser_bin:
134
- if not quiet:
135
- print(f"[*] Strategy 2: Found system browser at: {browser_bin}")
136
- if render_via_browser(browser_bin, html_path, pdf_path):
137
- if not quiet:
138
- print(f"[✓] Render Succeeded via System Browser -> {pdf_path}")
139
- return pdf_path
140
-
141
- raise RuntimeError("No suitable PDF rendering engine found. Please install Playwright or Google Chrome / Edge.")
142
18
 
143
19
  def main():
144
- parser = argparse.ArgumentParser(description="KnowMe CareerForge — Multi-Strategy PDF Renderer")
145
- parser.add_argument("html", nargs="?", default="workspace/resume.html", help="Input HTML canvas path")
146
- parser.add_argument("pdf", nargs="?", default="workspace/resume.pdf", help="Output PDF path")
147
- parser.add_argument("--input", "-i", help="Input HTML (named option)")
148
- parser.add_argument("--output", "-o", help="Output PDF (named option)")
149
- parser.add_argument("--quiet", "-q", action="store_true", help="Quiet execution mode")
150
-
20
+ parser = argparse.ArgumentParser(description=__doc__)
21
+ parser.add_argument("html", nargs="?", default="workspace/resume.html")
22
+ parser.add_argument("pdf", nargs="?", default="workspace/resume.pdf")
23
+ parser.add_argument("--input", "-i")
24
+ parser.add_argument("--output", "-o")
25
+ parser.add_argument("--expected-pages", "-p", type=int, default=1)
26
+ parser.add_argument("--auto-heal", action="store_true")
27
+ parser.add_argument("--quiet", "-q", action="store_true")
28
+ parser.add_argument("--json", action="store_true")
151
29
  args = parser.parse_args()
152
- input_html = args.input or args.html
153
- output_pdf = args.output or args.pdf
30
+ result = run(args.input or args.html, expected_pages=args.expected_pages,
31
+ auto_heal=args.auto_heal, output_pdf=args.output or args.pdf)
32
+ print(json.dumps(result, ensure_ascii=False))
33
+ return exit_code(result)
154
34
 
155
- try:
156
- render_pdf(input_html, output_pdf, quiet=args.quiet)
157
- except Exception as e:
158
- print(f"[✗] PDF Generation Failed: {e}", file=sys.stderr)
159
- sys.exit(1)
160
35
 
161
36
  if __name__ == "__main__":
162
- main()
37
+ raise SystemExit(main())