// Exploratory HTTP probe only; no product settings or user documents are read. import * as fs from 'node:fs/promises'; import path from 'node:path'; import { parseArgs } from 'node:util'; import { createHash } from 'node:crypto'; import { Buffer } from 'node:buffer'; import process from 'node:process'; import console from 'node:console'; import { URL } from 'node:url'; import { createCanvas, loadImage } from '@napi-rs/canvas'; const { fetch, AbortSignal } = globalThis; async function main() { const { values } = parseArgs({ options: { endpoint: { type: 'string' }, out: { type: 'string' }, mode: { type: 'string', default: 'discover' }, 'timeout-ms': { type: 'string', default: '60000' }, help: { type: 'boolean', default: false } } }); if (values.help) { console.log('node scripts/paddleocr-vl-probe.mjs --endpoint --out [--mode discover|full] [--timeout-ms 60000]\nCreates an exclusive run directory. discover: 2 GETs; full: additionally 4 layout calls and 2 restructure calls. No retries, redirects, URL image fetches, credentials, or user files.'); return; } if (!values.endpoint || !values.out) throw new Error('--endpoint and --out are required'); const endpoint = new URL(values.endpoint); if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error('Use an HTTP(S) base URL without credentials, query or fragment'); if (!['discover', 'full'].includes(values.mode)) throw new Error('Invalid mode'); const timeout = Number(values['timeout-ms']); if (!Number.isInteger(timeout) || timeout < 1000 || timeout > 120000) throw new Error('Timeout must be 1000..120000 ms'); const parent = path.resolve(values.out); if (!(await fs.stat(parent)).isDirectory()) throw new Error('--out must already exist'); const directory = await fs.mkdtemp(path.join(parent, 'paddleocr-vl-')); const report = { date: new Date().toISOString(), mode: values.mode, timeoutMs: timeout, calls: { get: 0, layout: 0, restructure: 0 }, checks: [], requests: [] }; const save = (name, data) => fs.writeFile(path.join(directory, name), typeof data === 'string' || Buffer.isBuffer(data) ? data : JSON.stringify(data, null, 2)); const check = (name, passed) => { report.checks.push({ name, passed: Boolean(passed) }); }; async function request(route, body) { report.calls[body ? (route === 'layout-parsing' ? 'layout' : 'restructure') : 'get']++; const started = Date.now(); const record = { route, method: body ? 'POST' : 'GET' }; report.requests.push(record); const response = await fetch(`${endpoint.href.replace(/\/$/, '')}/${route}`, { method: record.method, redirect: 'error', signal: AbortSignal.timeout(timeout), headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined }); record.status = response.status; const chunks = []; let size = 0; for await (const chunk of response.body) { size += chunk.length; if (size > 32 * 1024 * 1024) throw new Error('Response exceeds 32 MiB'); chunks.push(chunk); } record.bytes = size; record.durationMs = Date.now() - started; if (!response.ok) throw new Error(`${route}: HTTP ${response.status}`); const data = JSON.parse(Buffer.concat(chunks).toString('utf8')); if (data.errorCode !== undefined && data.errorCode !== 0) throw new Error(`${route}: service error code ${data.errorCode}`); return data; } try { const health = await request('health'); check('health response', health !== null); const schema = await request('openapi.json'); await save('openapi.json', schema); const schemas = schema.components?.schemas ?? {}; await save('options.json', Object.fromEntries(Object.entries(schemas).filter(([name]) => /layout|restructure|InferRequest|^Page$|MarkdownData/i.test(name)))); check('layout route advertised', schema.paths?.['/layout-parsing']?.post); check('restructure route advertised', schema.paths?.['/restructure-pages']?.post); if (values.mode === 'full') await runFixtures(request, save, check, report); } catch (error) { // Do not print server bodies, endpoint URLs, or arbitrary fetch errors. report.failure = error instanceof Error && /^(Response exceeds|layout-parsing:|restructure-pages:)/.test(error.message) ? error.message : `Probe failed (${error.name ?? 'Error'}); inspect request status and local fixture assertions`; process.exitCode = 1; } finally { if (report.checks.some(item => !item.passed)) process.exitCode = 1; await save('report.json', report); console.log(JSON.stringify({ directory, ...report }, null, 2)); } } async function runFixtures(request, save, check, report) { const canvas = createCanvas(1200, 1600); const ctx = canvas.getContext('2d'); function draw(page) { ctx.fillStyle = 'white'; ctx.fillRect(0, 0, 1200, 1600); ctx.fillStyle = 'black'; ctx.font = '22px sans-serif'; ctx.fillText('SYNTHETIC HEADER - GOODBUDDY LAB', 100, 55); ctx.font = 'bold 40px sans-serif'; ctx.fillText('GoodBuddy OCR Report', 100, 170); ctx.font = '30px "Microsoft YaHei", "Noto Sans CJK SC", sans-serif'; ctx.fillText('Bilingual test: \u6587\u6863\u89e3\u6790\u6d4b\u8bd5', 100, 250); ctx.fillText(`Page ${page} body marker: Amount 1234.56 USD`, 100, 320); ctx.fillText('Figure 1. Synthetic landscape illustration', 100, 390); ctx.fillStyle = '#a7d8ed'; ctx.fillRect(150, 430, 900, 480); ctx.fillStyle = '#ffd35a'; ctx.beginPath(); ctx.arc(830, 535, 65, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#507e4d'; ctx.beginPath(); ctx.moveTo(150, 910); ctx.lineTo(450, 560); ctx.lineTo(740, 910); ctx.fill(); ctx.fillStyle = '#366443'; ctx.beginPath(); ctx.moveTo(480, 910); ctx.lineTo(800, 650); ctx.lineTo(1050, 910); ctx.fill(); ctx.fillStyle = 'black'; ctx.fillText(`Table 1${page === 2 ? ' (continued)' : ''}. Inventory`, 100, 1020); ctx.lineWidth = 2; for (let row = 0; row <= 3; row++) { ctx.beginPath(); ctx.moveTo(100, 1060 + row * 90); ctx.lineTo(1100, 1060 + row * 90); ctx.stroke(); } for (const x of [100, 650, 1100]) { ctx.beginPath(); ctx.moveTo(x, 1060); ctx.lineTo(x, 1330); ctx.stroke(); } for (const [i, cells] of [['Item', 'Quantity'], [`Sample ${page * 2 - 1}`, '12'], [`Sample ${page * 2}`, '24']].entries()) { ctx.fillText(cells[0], 120, 1120 + i * 90); ctx.fillText(cells[1], 680, 1120 + i * 90); } ctx.font = '22px sans-serif'; ctx.fillText('SYNTHETIC FOOTER - INTERNAL TEST', 100, 1520); ctx.fillText(String(page), 1080, 1520); } draw(1); const png = canvas.toBuffer('image/png'); await save('fixture.png', png); const jpeg1 = canvas.toBuffer('image/jpeg'); draw(2); const pdf = makePdf([jpeg1, canvas.toBuffer('image/jpeg')]); await save('fixture.pdf', pdf); async function summarize(name, data) { const pages = data.result?.layoutParsingResults; check(`${name}: page results`, Array.isArray(pages) && pages.length > 0); if (!Array.isArray(pages) || pages.length > 2) throw new Error('Unexpected page results'); const summary = []; for (const [index, page] of pages.entries()) { const markdown = page.markdown?.text ?? ''; await save(`${name}-page-${index + 1}.md`, markdown); const images = []; for (const [key, value] of Object.entries(page.markdown?.images ?? {})) { const item = { key, kind: typeof value }; if (typeof value === 'string' && /^https?:\/\//i.test(value)) item.kind = 'url-not-fetched'; else if (typeof value === 'string') { const encoded = value.replace(/^data:image\/[\w.+-]+;base64,/, ''); if (/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) && encoded.length % 4 === 0) { const bytes = Buffer.from(encoded, 'base64'); const image = await loadImage(bytes); item.kind = value.startsWith('data:') ? 'data-uri' : 'base64'; item.bytes = bytes.length; item.width = image.width; item.height = image.height; item.sha256 = createHash('sha256').update(bytes).digest('hex'); const extension = bytes.subarray(0, 3).equals(Buffer.from([255, 216, 255])) ? 'jpg' : 'png'; item.file = `${name}-page-${index + 1}-image-${images.length + 1}.${extension}`; await save(item.file, bytes); } else item.kind = 'unrecognized-string'; } item.linked = markdown.includes(key); images.push(item); } summary.push({ responseOrdinal: index + 1, pageIndex: page.prunedResult?.page_index ?? null, pageCount: page.prunedResult?.page_count, keys: Object.keys(page), prunedKeys: Object.keys(page.prunedResult ?? {}), labels: page.prunedResult?.parsing_res_list?.map(block => block.block_label), characters: markdown.length, textPreview: markdown.slice(0, 180), header: markdown.includes('SYNTHETIC HEADER'), footer: markdown.includes('SYNTHETIC FOOTER'), table: / page.markdown.text.includes(`Page ${index + 1}`))); check('fixture: table recognized', report.pdf.some(page => page.table)); check('fixture: image artifact returned', [...report.pdf, ...report['image-default']].some(page => page.images.some(image => image.linked))); const restructureTexts = []; for (const enabled of [false, true]) { const name = `restructure-${enabled}`; const body = { pages: pages.map(page => ({ prunedResult: page.prunedResult, markdownImages: page.markdown.images })), mergeTables: enabled, relevelTitles: enabled }; await save(`${name}-options.json`, { mergeTables: enabled, relevelTitles: enabled }); const data = await request('restructure-pages', body); const restructured = await summarize(name, data); restructureTexts.push(restructured.map(page => page.markdown.text).join('\n\n')); check(`${name}: page markers retained`, [1, 2].every(index => restructured.some(page => page.markdown.text.includes(`Page ${index}`)))); check(`${name}: images retained`, report[name].every(page => page.images.some(image => image.linked))); } report.restructureMarkdownChanged = restructureTexts[0] !== restructureTexts[1]; } function makePdf(images) { const objects = [Buffer.from('<< /Type /Catalog /Pages 2 0 R >>'), Buffer.from('<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>')]; for (const [i, image] of images.entries()) { const first = 3 + i * 3; const stream = 'q 600 0 0 800 0 0 cm /Im0 Do Q'; objects.push(Buffer.from(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /XObject << /Im0 ${first + 2} 0 R >> >> /Contents ${first + 1} 0 R >>`)); objects.push(Buffer.from(`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`)); objects.push(Buffer.concat([Buffer.from(`<< /Type /XObject /Subtype /Image /Width 1200 /Height 1600 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${image.length} >>\nstream\n`), image, Buffer.from('\nendstream')])); } const parts = [Buffer.from('%PDF-1.4\n')]; const offsets = []; let length = parts[0].length; objects.forEach((object, index) => { offsets.push(length); const part = Buffer.concat([Buffer.from(`${index + 1} 0 obj\n`), object, Buffer.from('\nendobj\n')]); parts.push(part); length += part.length; }); parts.push(Buffer.from(`xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.map(offset => `${String(offset).padStart(10, '0')} 00000 n \n`).join('')}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${length}\n%%EOF\n`)); return Buffer.concat(parts); } main().catch(() => { console.error('Invalid probe arguments or output directory; use --help.'); process.exitCode = 1; });