174 lines
10 KiB
JavaScript

import path from 'node:path'
import fs from 'node:fs'
import { StaticCanvas, FabricImage, Textbox } from 'fabric/node'
import { createCanvas, createImageData, registerFont } from 'canvas'
import sharp from 'sharp'
import { initializeCanvas, writePsdBuffer } from 'ag-psd'
const FONT_FAMILY = 'Noto Sans SC'
const MAX_SCREENS = 12
const MAX_HEIGHT = 30000 // PSD v1 dimensions, and a bounded raster allocation.
const MAX_ASSET_BYTES = 20 * 1024 * 1024
let fontsDirectory = null
function initializeFonts(directory) {
if (fontsDirectory === directory) return
for (const [weight, filename] of [['400', 'NotoSansSC-Regular.otf'], ['700', 'NotoSansSC-Bold.otf']]) {
const filenamePath = path.join(directory, filename)
if (!fs.existsSync(filenamePath)) throw new Error('详情页字体未安装:请提供完整 Noto Sans SC 字体文件')
registerFont(filenamePath, { family: FONT_FAMILY, weight, style: 'normal' })
}
initializeCanvas(createCanvas, createImageData)
fontsDirectory = directory
}
function finite(value, min, max, name) {
if (!Number.isFinite(value) || value < min || value > max) throw new Error(`无效的渲染参数:${name}`)
}
export function validateRequest(request) {
if (!request || !['png', 'jpg', 'long-png', 'psd'].includes(request.format)) throw new Error('不支持的导出格式')
const screens = request.document?.screens
if (!Array.isArray(screens) || screens.length < 1 || screens.length > MAX_SCREENS) throw new Error('导出需要 1 至 12 屏')
let totalHeight = 0
for (const screen of screens) {
if (screen.width !== 750 || !Number.isInteger(screen.height)) throw new Error('详情页宽度必须为 750px')
finite(screen.height, 400, 2400, 'screen.height')
totalHeight += screen.height
if (!/^#[0-9a-f]{6}$/i.test(screen.backgroundColor)) throw new Error('无效的背景颜色')
if (!Array.isArray(screen.texts) || screen.texts.length > 20) throw new Error('每屏最多 20 个文字图层')
for (const text of screen.texts) {
if (typeof text.text !== 'string' || text.text.length > 3000 || text.fontFamily !== FONT_FAMILY) throw new Error('无效的文字图层')
finite(text.x, 0, 750, 'text.x'); finite(text.y, 0, screen.height, 'text.y')
finite(text.width, 1, 750 - text.x, 'text.width'); finite(text.fontSize, 12, 120, 'text.fontSize')
finite(text.lineHeight, 1, 2.5, 'text.lineHeight')
if (!['left', 'center', 'right'].includes(text.align) || !/^#[0-9a-f]{6}$/i.test(text.color)) throw new Error('无效的文字样式')
}
for (const layer of [screen.image, screen.logo].filter(Boolean)) {
finite(layer.x, 0, 749, 'image.x'); finite(layer.y, 0, screen.height - 1, 'image.y')
finite(layer.width, 1, 750 - layer.x, 'image.width'); finite(layer.height, 1, screen.height - layer.y, 'image.height')
const asset = request.assets?.[layer.assetId]
if (!asset || !['image/png', 'image/jpeg', 'image/webp'].includes(asset.mimeType) || typeof asset.base64 !== 'string') throw new Error('缺少可用的图片素材')
if (asset.base64.length > Math.ceil(MAX_ASSET_BYTES * 4 / 3) + 4) throw new Error('导出图片素材过大')
}
}
if (totalHeight > MAX_HEIGHT) throw new Error('详情页总高度超过导出上限')
}
function textBox(layer, x = layer.x, y = layer.y) {
return new Textbox(layer.text, {
left: x, top: y, originX: 'left', originY: 'top', width: layer.width, fontSize: layer.fontSize,
fontFamily: FONT_FAMILY, fontWeight: '400', lineHeight: layer.lineHeight,
fill: layer.color, textAlign: layer.align, splitByGrapheme: true,
padding: 0, strokeWidth: 0, objectCaching: false,
})
}
export function coverGeometry(sourceWidth, sourceHeight, width, height) {
const scale = Math.max(width / sourceWidth, height / sourceHeight)
return { cropX: (sourceWidth - width / scale) / 2, cropY: (sourceHeight - height / scale) / 2, width: width / scale, height: height / scale, scaleX: scale, scaleY: scale }
}
export function containGeometry(sourceWidth, sourceHeight, width, height) {
const scale = Math.min(width / sourceWidth, height / sourceHeight)
return { cropX: 0, cropY: 0, width: sourceWidth, height: sourceHeight, scaleX: scale, scaleY: scale, offsetX: (width - sourceWidth * scale) / 2, offsetY: (height - sourceHeight * scale) / 2 }
}
async function imageObject(layer, asset, contain = false) {
const image = await sharp(Buffer.from(asset.base64, 'base64'), { failOn: 'error', limitInputPixels: 25000000, animated: false }).rotate().png().toBuffer()
const object = await FabricImage.fromURL(`data:image/png;base64,${image.toString('base64')}`)
const geometry = contain ? containGeometry(object.width, object.height, layer.width, layer.height) : coverGeometry(object.width, object.height, layer.width, layer.height)
object.set({ left: layer.x + (geometry.offsetX || 0), top: layer.y + (geometry.offsetY || 0), originX: 'left', originY: 'top', ...geometry, strokeWidth: 0, objectCaching: false })
return object
}
function pngFromCanvas(canvas) {
canvas.renderAll()
return Buffer.from(canvas.toDataURL({ format: 'png', multiplier: 1 }).split(',')[1], 'base64')
}
async function rawPixels(png) {
const { data, info } = await sharp(png).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
return { data: new Uint8ClampedArray(data), width: info.width, height: info.height }
}
async function layerBitmap(object, width, height, origin = { x: object.left, y: object.top }) {
const canvas = new StaticCanvas(null, { width: Math.ceil(width), height: Math.ceil(height), renderOnAddRemove: false, enableRetinaScaling: false })
try {
const clone = await object.clone()
clone.set({ left: object.left - origin.x, top: object.top - origin.y })
canvas.add(clone)
return await rawPixels(pngFromCanvas(canvas))
} finally { await canvas.dispose() }
}
async function renderScreen(screen, assets, offset, includeLayers) {
const canvas = new StaticCanvas(null, { width: screen.width, height: screen.height, backgroundColor: screen.backgroundColor, renderOnAddRemove: false, enableRetinaScaling: false })
const children = []
try {
if (includeLayers) {
const background = await sharp({ create: { width: screen.width, height: screen.height, channels: 4, background: screen.backgroundColor } }).png().toBuffer()
children.push({ name: '背景', left: 0, top: offset, imageData: await rawPixels(background) })
}
if (screen.image) {
const object = await imageObject(screen.image, assets[screen.image.assetId])
canvas.add(object)
if (includeLayers) children.push({ name: '产品场景', left: Math.round(screen.image.x), top: Math.round(offset + screen.image.y), imageData: await layerBitmap(object, screen.image.width, screen.image.height) })
}
for (let index = 0; index < screen.texts.length; index++) {
const layer = screen.texts[index]
if (!layer.text) continue
const object = textBox(layer)
if (layer.y + object.height > screen.height + 1) throw new Error(`「${screen.title}」文字超出画布,请缩短文案或调整排版`)
canvas.add(object)
if (includeLayers) {
const color = layer.color.slice(1)
// Explicit initial line breaks retain Fabric's CJK wrapping when Photoshop opens the file.
const wrapped = object._textLines.map(line => line.join('')).join('\n')
children.push({
name: index === 0 ? '标题' : `正文 ${index}`, left: Math.round(layer.x), top: Math.round(offset + layer.y),
imageData: await layerBitmap(object, layer.width, object.height),
text: {
text: wrapped, transform: [1, 0, 0, 1, layer.x, offset + layer.y], orientation: 'horizontal', antiAlias: 'smooth',
shapeType: 'box', boxBounds: [0, 0, layer.width, Math.ceil(object.height + layer.fontSize)],
style: { font: { name: 'NotoSansSC-Regular' }, fontSize: layer.fontSize, autoLeading: false, leading: object.getHeightOfLine(0),
fillColor: { r: parseInt(color.slice(0, 2), 16), g: parseInt(color.slice(2, 4), 16), b: parseInt(color.slice(4, 6), 16) } },
paragraphStyle: { justification: layer.align, autoHyphenate: false },
},
})
}
}
if (screen.logo) {
const object = await imageObject(screen.logo, assets[screen.logo.assetId], true)
canvas.add(object)
if (includeLayers) children.push({ name: '品牌标志', left: Math.round(screen.logo.x), top: Math.round(offset + screen.logo.y), imageData: await layerBitmap(object, screen.logo.width, screen.logo.height, screen.logo) })
}
return { png: pngFromCanvas(canvas), group: { name: screen.title || `第 ${offset} 屏`, opened: true, children } }
} finally { await canvas.dispose() }
}
export async function renderRequest(request, options = {}) {
validateRequest(request)
initializeFonts(options.fontDirectory || process.env.DETAIL_FONT_DIR || path.resolve(process.cwd(), 'public/detail-generator/fonts'))
const rendered = []
let offset = 0
for (const screen of request.document.screens) {
rendered.push(await renderScreen(screen, request.assets, offset, request.format === 'psd'))
offset += screen.height
}
if (request.format === 'png' || request.format === 'jpg') {
return Promise.all(rendered.map(async ({ png }, index) => ({ filename: `screen-${String(index + 1).padStart(2, '0')}.${request.format}`, mimeType: request.format === 'png' ? 'image/png' : 'image/jpeg',
base64: (request.format === 'png' ? png : await sharp(png).flatten({ background: '#ffffff' }).jpeg({ quality: 95, chromaSubsampling: '4:4:4' }).toBuffer()).toString('base64') })))
}
let top = 0
const composites = rendered.map((screen, index) => {
const item = { input: screen.png, left: 0, top }
top += request.document.screens[index].height
return item
})
const composite = await sharp({ create: { width: 750, height: offset, channels: 4, background: '#ffffff' } }).composite(composites).png().toBuffer()
if (request.format === 'long-png') return [{ filename: 'detail-long.png', mimeType: 'image/png', base64: composite.toString('base64') }]
const psd = writePsdBuffer({ width: 750, height: offset, bitsPerChannel: 8, imageData: await rawPixels(composite), children: rendered.map(screen => screen.group) }, { generateThumbnail: false, invalidateTextLayers: true })
return [{ filename: 'detail-layered.psd', mimeType: 'image/vnd.adobe.photoshop', base64: psd.toString('base64') }]
}