174 lines
10 KiB
JavaScript
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') }]
|
|
}
|