56 lines
4.4 KiB
TypeScript
56 lines
4.4 KiB
TypeScript
export interface LabRun { start: number; end: number; font: string; size: number; color: string }
|
|
export interface LabLayer {
|
|
id: string; name: string; kind: 'text' | 'image' | 'shape'; sourceId: number
|
|
x: number; y: number; width: number; height: number; locked: boolean
|
|
text?: string; originalText?: string; originalLines?: number; runs?: LabRun[]; font?: string; fontSize?: number; color?: string
|
|
textLayout?: 'box'
|
|
angle?: number; lineHeight?: number; source?: string; opacity?: number
|
|
crop?: { zoom: number; x: number; y: number; fit: 'cover' | 'contain' }
|
|
shape?: 'rect' | 'line' | 'bracket-left' | 'bracket-right'; radius?: number
|
|
points?: number[]; defaultLocked?: boolean
|
|
}
|
|
export interface LabScreen { id: string; name: string; width: number; height: number; background: string; reference?: string; thumbnail?: string; layers: LabLayer[] }
|
|
export interface LabFixture { template: string; version: string; fonts: { name: string; url: string }[]; screens: LabScreen[] }
|
|
export interface LabDraft { version: 1; templateVersion: string; screens: LabScreen[]; savedAt: string }
|
|
export const MAX_REPLACEMENT_BYTES = 20 * 1024 * 1024
|
|
export const MAX_REPLACEMENT_PIXELS = 24_000_000
|
|
export const MAX_EXPORT_PIXELS = 6_000_000
|
|
|
|
/** Pure geometry shared by preview and tests. A crop never stretches the source. */
|
|
export function imageGeometry(sw: number, sh: number, width: number, height: number, crop?: LabLayer['crop']) {
|
|
if (![sw, sh, width, height].every(v => Number.isFinite(v) && v > 0)) throw new Error('图片或图框尺寸无效')
|
|
const clamp = (v: number, low: number, high: number) => Math.max(low, Math.min(high, v))
|
|
const fit = crop?.fit || 'cover'
|
|
const zoom = clamp(crop?.zoom || 1, 1, 3)
|
|
const scale = (fit === 'contain' ? Math.min(width / sw, height / sh) : Math.max(width / sw, height / sh)) * zoom
|
|
const cw = Math.min(sw, width / scale), ch = Math.min(sh, height / scale)
|
|
return { scale, width: cw, height: ch, cropX: (sw - cw) * clamp(crop?.x ?? .5, 0, 1), cropY: (sh - ch) * clamp(crop?.y ?? .5, 0, 1), offsetX: (width - cw * scale) / 2, offsetY: (height - ch * scale) / 2 }
|
|
}
|
|
|
|
export function patchLayer(screens: LabScreen[], screenId: string, layerId: string, patch: Partial<LabLayer>): LabScreen[] {
|
|
return screens.map(screen => screen.id !== screenId ? screen : { ...screen, layers: screen.layers.map(layer => layer.id !== layerId ? layer : { ...layer, ...patch }) })
|
|
}
|
|
|
|
export function canExport(screen: LabScreen) { return screen.width * screen.height <= MAX_EXPORT_PIXELS }
|
|
export function rasterMiB(width: number, height: number, copies = 1) { return width * height * 4 * copies / 1024 ** 2 }
|
|
|
|
/** Local drafts cannot add network locations, code, huge canvases or unknown template objects. */
|
|
export function validateDraft(value: unknown, fixture: LabFixture): value is LabDraft {
|
|
const d = value as LabDraft
|
|
if (!d || d.version !== 1 || d.templateVersion !== fixture.version || !Array.isArray(d.screens) || d.screens.length !== fixture.screens.length) return false
|
|
return d.screens.every((screen, index) => {
|
|
const original = fixture.screens[index]
|
|
if (screen.id !== original.id || screen.width !== original.width || screen.height !== original.height || !Array.isArray(screen.layers) || screen.layers.length !== original.layers.length) return false
|
|
return screen.layers.every((layer, position) => {
|
|
const base = original.layers[position]
|
|
if (layer.id !== base.id || layer.kind !== base.kind || layer.sourceId !== base.sourceId) return false
|
|
if (![layer.x, layer.y, layer.width, layer.height].every(Number.isFinite) || layer.width < 1 || layer.height < 1 || layer.width > 6000 || layer.height > 6000 || Math.abs(layer.x) > 6000 || Math.abs(layer.y) > 6000) return false
|
|
if (layer.text != null && (typeof layer.text !== 'string' || layer.text.length > 2000)) return false
|
|
if (layer.fontSize != null && (!Number.isFinite(layer.fontSize) || layer.fontSize < 8 || layer.fontSize > 240)) return false
|
|
if (layer.source && layer.source !== base.source && !/^local:[a-zA-Z0-9-]+$/.test(layer.source) && !fixture.screens.some(s => s.layers.some(l => l.kind === 'image' && l.source === layer.source))) return false
|
|
if (layer.crop && (!['cover', 'contain'].includes(layer.crop.fit) || ![layer.crop.zoom, layer.crop.x, layer.crop.y].every(Number.isFinite) || layer.crop.zoom < 1 || layer.crop.zoom > 3 || layer.crop.x < 0 || layer.crop.x > 1 || layer.crop.y < 0 || layer.crop.y > 1)) return false
|
|
return true
|
|
})
|
|
})
|
|
}
|