import test from 'node:test' import assert from 'node:assert/strict' import fs from 'node:fs/promises' import path from 'node:path' import { spawn } from 'node:child_process' import { fileURLToPath } from 'node:url' import sharp from 'sharp' import { readPsd } from 'ag-psd' import { renderRequest, coverGeometry, containGeometry } from './render.mjs' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') const options = { fontDirectory: path.join(root, 'public/detail-generator/fonts') } const text = '真实橡木座椅\n清晰的产品卖点与中文换行' function document(screens = 1) { return { version: 1, name: '中文详情页验收', screens: Array.from({ length: screens }, (_, index) => ({ id: `screen-${index}`, title: `第 ${index + 1} 屏`, width: 750, height: 900, backgroundColor: '#f6f5f2', image: { assetId: 'product', x: 50, y: 240, width: 650, height: 600 }, logo: { assetId: 'logo', x: 640, y: 30, width: 60, height: 60 }, texts: [{ id: 'title', text, x: 50, y: 80, width: 630, fontSize: 40, lineHeight: 1.4, color: '#182a36', fontFamily: 'Noto Sans SC', align: 'left' }], })) } } async function assets() { const product = await sharp({ create: { width: 800, height: 500, channels: 4, background: '#d48756' } }).composite([{ input: { text: { text: '产品图片', width: 300, height: 80, font: 'sans 48', rgba: true } }, left: 250, top: 210 }]).png().toBuffer() const logo = await sharp({ create: { width: 60, height: 60, channels: 4, background: '#2488ac' } }).png().toBuffer() return { product: { mimeType: 'image/png', base64: product.toString('base64') }, logo: { mimeType: 'image/png', base64: logo.toString('base64') } } } test('center cover cropping preserves proportions', () => { const geometry = coverGeometry(800, 500, 650, 600) assert.equal(geometry.scaleX, geometry.scaleY) assert.equal(geometry.height, 500) assert.equal(geometry.cropY, 0) assert.ok(geometry.cropX > 0) const logo = containGeometry(300, 60, 60, 60) assert.equal(logo.scaleX, 0.2) assert.equal(logo.offsetY, 24) assert.equal(logo.cropX, 0) }) test('font files contain the exact PostScript names used by native PSD text layers', async () => { for (const name of ['NotoSansSC-Regular', 'NotoSansSC-Bold']) { const font = await fs.readFile(path.join(options.fontDirectory, `${name}.otf`)) let nameTable = 0 for (let i = 0; i < font.readUInt16BE(4); i++) { const record = 12 + 16 * i if (font.subarray(record, record + 4).toString('ascii') === 'name') nameTable = font.readUInt32BE(record + 8) } assert.ok(nameTable) const names = [] const strings = nameTable + font.readUInt16BE(nameTable + 4) for (let i = 0; i < font.readUInt16BE(nameTable + 2); i++) { const record = nameTable + 6 + 12 * i if (font.readUInt16BE(record + 6) !== 6) continue const start = strings + font.readUInt16BE(record + 10) const bytes = font.subarray(start, start + font.readUInt16BE(record + 8)) names.push(font.readUInt16BE(record) === 3 ? Buffer.from(bytes).swap16().toString('utf16le') : bytes.toString('ascii')) } assert.ok(names.includes(name), `Bundled font PostScript name must be ${name}`) } }) test('Chinese text, logo, screen images, long image, and editable PSD agree', async () => { const request = { document: document(2), assets: await assets(), format: 'png' } const files = await renderRequest(request, options) assert.equal(files.length, 2) const png = Buffer.from(files[0].base64, 'base64') const metadata = await sharp(png).metadata() assert.equal(metadata.width, 750); assert.equal(metadata.height, 900) const raw = await sharp(png).ensureAlpha().raw().toBuffer() const pixel = (x, y) => Array.from(raw.subarray((y * 750 + x) * 4, (y * 750 + x) * 4 + 3)) assert.deepEqual(pixel(670, 60), [36, 136, 172]) // logo remains distinct from the scene. assert.deepEqual(pixel(100, 350), [212, 135, 86]) let textPixels = 0 for (let y = 80; y < 210; y++) for (let x = 50; x < 680; x++) { const [r, g, b] = pixel(x, y) if (r < 100 && g < 100 && b < 100) textPixels++ } assert.ok(textPixels > 1000, 'Chinese text must be visibly rendered with the bundled font') const [jpg] = await renderRequest({ ...request, document: document(), format: 'jpg' }, options) assert.equal((await sharp(Buffer.from(jpg.base64, 'base64')).metadata()).format, 'jpeg') const [long] = await renderRequest({ ...request, format: 'long-png' }, options) const longBytes = Buffer.from(long.base64, 'base64') assert.equal((await sharp(longBytes).metadata()).height, 1800) const firstScreen = await sharp(longBytes).extract({ left: 0, top: 0, width: 750, height: 900 }).png().toBuffer() assert.deepEqual(await sharp(firstScreen).raw().toBuffer(), await sharp(png).raw().toBuffer()) const [psdFile] = await renderRequest({ ...request, format: 'psd' }, options) const psdBytes = Buffer.from(psdFile.base64, 'base64') assert.equal(psdBytes.subarray(0, 4).toString('ascii'), '8BPS') const psd = readPsd(psdBytes, { useImageData: true, skipThumbnail: true }) assert.equal(psd.width, 750); assert.equal(psd.height, 1800) assert.equal(psd.children.length, 2) const title = psd.children[0].children.find(layer => layer.name === '标题') assert.equal(title.text.text, text) assert.equal(title.text.style.font.name, 'NotoSansSC-Regular') assert.equal(title.text.orientation, 'horizontal') assert.ok(Math.abs(title.text.style.leading - 40 * 1.13 * 1.4) < 0.001) assert.equal(psd.children[1].children.find(layer => layer.name === '标题').text.transform[5], 980) assert.ok(psd.children[0].children.some(layer => layer.name === '产品场景')) assert.ok(psd.children[0].children.some(layer => layer.name === '品牌标志')) assert.deepEqual(Buffer.from(psd.imageData.data), await sharp(longBytes).ensureAlpha().raw().toBuffer()) if (process.env.DETAIL_RENDER_TEST_ARTIFACTS === '1') { const output = path.join(root, 'output/detail-generator-render') await fs.mkdir(output, { recursive: true }) await Promise.all([fs.writeFile(path.join(output, 'screen-01.png'), png), fs.writeFile(path.join(output, 'detail-long.png'), longBytes), fs.writeFile(path.join(output, 'detail-layered.psd'), psdBytes)]) } }) test('isolated CLI accepts bytes, emits a valid PNG, and never needs a URL fetch', async () => { const input = JSON.stringify({ document: document(), assets: await assets(), format: 'png' }) const output = await new Promise((resolve, reject) => { const child = spawn(process.execPath, [path.join(root, 'workers/detail-renderer/cli.mjs')], { cwd: root, env: { ...process.env, DETAIL_FONT_DIR: options.fontDirectory }, stdio: ['pipe', 'pipe', 'pipe'] }) const stdout = []; let stderr = '' child.stdout.on('data', chunk => stdout.push(chunk)) child.stderr.on('data', chunk => { stderr += chunk.toString() }) child.on('error', reject) child.on('close', code => code === 0 ? resolve(Buffer.concat(stdout).toString()) : reject(new Error(stderr))) child.stdin.end(input) }) const result = JSON.parse(output) assert.equal(result.files[0].filename, 'screen-01.png') const metadata = await sharp(Buffer.from(result.files[0].base64, 'base64')).metadata() assert.equal(metadata.width, 750) assert.equal(metadata.height, 900) }) test('wide brand logos retain their full aspect ratio in PNG and the PSD layer', async () => { const inputAssets = await assets() const wideLogo = await sharp({ create: { width: 300, height: 60, channels: 4, background: '#2488ac' } }).png().toBuffer() inputAssets.logo.base64 = wideLogo.toString('base64') const request = { document: document(), assets: inputAssets, format: 'png' } const [pngFile] = await renderRequest(request, options) const pixels = await sharp(Buffer.from(pngFile.base64, 'base64')).ensureAlpha().raw().toBuffer() const pixel = (x, y) => Array.from(pixels.subarray((y * 750 + x) * 4, (y * 750 + x) * 4 + 3)) assert.deepEqual(pixel(670, 35), [246, 245, 242]) assert.deepEqual(pixel(670, 60), [36, 136, 172]) const [psdFile] = await renderRequest({ ...request, format: 'psd' }, options) const psd = readPsd(Buffer.from(psdFile.base64, 'base64'), { useImageData: true, skipThumbnail: true }) const logo = psd.children[0].children.find(layer => layer.name === '品牌标志') assert.equal(logo.top, 30) assert.equal(logo.bottom - logo.top, 60) assert.equal(logo.right - logo.left, 60) assert.equal(logo.imageData.data[(5 * 60 + 30) * 4 + 3], 0) assert.equal(logo.imageData.data[(30 * 60 + 30) * 4 + 3], 255) assert.deepEqual(Buffer.from(psd.imageData.data), pixels) }) test('missing images, arbitrary URL sources, and overflowing text fail explicitly', async () => { const request = { document: document(), assets: {}, format: 'png' } await assert.rejects(renderRequest(request, options), /缺少可用的图片素材/) await assert.rejects(renderRequest({ ...request, assets: { product: { mimeType: 'image/svg+xml', base64: '' } } }, options), /缺少可用的图片素材/) request.assets = await assets() request.document.screens[0].texts[0].text = '中文'.repeat(500) await assert.rejects(renderRequest(request, options), /文字超出画布/) })