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.
- package/AGENT.md +276 -0
- package/ARCHITECTURE.md +216 -0
- package/README.md +34 -54
- package/README.zh-CN.md +35 -57
- package/SKILL.md +28 -147
- package/agents/claude/knowme-careerforge.md +2 -17
- package/agents/codex/knowme-careerforge.yaml +3 -5
- package/agents/cursor/knowme-careerforge.mdc +4 -13
- package/agents/opencode/skill.yaml +1 -1
- package/agents/windsurf/knowme-careerforge.rules +2 -12
- 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 +5 -0
- package/references/02-career-goal.md +38 -0
- package/references/03-jd-analysis.md +44 -0
- package/references/04-template-selection.md +43 -0
- package/references/05-html-canvas-tokens.md +12 -0
- package/references/06-qa-and-rendering.md +14 -0
- package/references/07-artifact-contract.md +64 -0
- package/scripts/Agent.md +9 -136
- package/scripts/build/build-gallery.py +66 -56
- package/scripts/build/run-all-tests.py +1 -0
- package/scripts/build/sync-version.py +27 -9
- package/scripts/contracts/profile.py +101 -0
- package/scripts/evidence/extract-evidence.py +109 -62
- package/scripts/pipeline/forge.py +266 -136
- package/scripts/rendering/README.md +65 -0
- package/scripts/rendering/browser-engine.d.ts +15 -0
- package/scripts/rendering/browser-engine.js +366 -0
- package/scripts/rendering/browser_engine.py +39 -0
- package/scripts/rendering/engine-loader.ts +11 -0
- package/scripts/rendering/render-pdf.py +26 -151
- package/scripts/rendering/render-pdf.ts +7 -83
- package/scripts/template/instantiate-resume.py +266 -173
- package/scripts/template/search-template.py +80 -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 +1 -1
- 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 +3 -39
- package/src/references/05-html-canvas-tokens.md +6 -45
- package/src/references/06-qa-and-rendering.md +7 -47
- package/src/references/07-artifact-contract.md +64 -0
- package/src/templates/Agent.md +11 -0
- package/src/templates/academic-research/README.md +25 -0
- package/src/templates/academic-research/canvas.html +21 -0
- package/src/templates/academic-research/metadata.json +38 -0
- package/src/templates/academic-research/sample-profile.json +86 -0
- package/src/templates/academic-research/style.css +213 -0
- package/src/templates/classic/README.md +9 -0
- package/src/templates/classic/canvas.html +21 -0
- package/src/templates/classic/metadata.json +3 -0
- package/src/templates/classic/sample-profile.json +86 -0
- package/src/templates/classic/style.css +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 +9 -0
- package/src/templates/minimal/canvas.html +21 -0
- package/src/templates/minimal/sample-profile.json +86 -0
- package/src/templates/minimal/style.css +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,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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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=
|
|
145
|
-
parser.add_argument("html", nargs="?", default="workspace/resume.html"
|
|
146
|
-
parser.add_argument("pdf", nargs="?", default="workspace/resume.pdf"
|
|
147
|
-
parser.add_argument("--input", "-i"
|
|
148
|
-
parser.add_argument("--output", "-o"
|
|
149
|
-
parser.add_argument("--
|
|
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
|
-
|
|
153
|
-
|
|
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())
|