155 lines
9.1 KiB
JavaScript
155 lines
9.1 KiB
JavaScript
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: '<span foreground="white">产品图片</span>', 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: '<svg></svg>' } } }, options), /缺少可用的图片素材/)
|
|
request.assets = await assets()
|
|
request.document.screens[0].texts[0].text = '中文'.repeat(500)
|
|
await assert.rejects(renderRequest(request, options), /文字超出画布/)
|
|
})
|