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') }] }