/** 文本控件在元素框内的垂直对齐(config.verticalAlign) */ export type TextVerticalAlign = 'top' | 'center' | 'bottom'; export function readVerticalAlign( config: Record | undefined | null, ): TextVerticalAlign { const raw = config?.verticalAlign ?? config?.VerticalAlign; const v = String(raw ?? 'top').trim().toLowerCase(); if (v === 'center' || v === 'middle') return 'center'; if (v === 'bottom') return 'bottom'; return 'top'; } export function verticalAlignToFlexJustify( align: TextVerticalAlign, ): 'flex-start' | 'center' | 'flex-end' { if (align === 'center') return 'center'; if (align === 'bottom') return 'flex-end'; return 'flex-start'; } export function textAlignToFlexAlign( align: string | undefined, ): 'flex-start' | 'center' | 'flex-end' { const v = String(align ?? 'left').toLowerCase(); if (v === 'center') return 'center'; if (v === 'right') return 'flex-end'; return 'flex-start'; } /** 画布/位图:文本块在元素框内的顶部 Y 偏移(不含 baseline) */ export function computeVerticalTextBlockOffset( innerHeight: number, blockHeight: number, verticalAlign: TextVerticalAlign, ): number { const extra = Math.max(0, innerHeight - blockHeight); if (verticalAlign === 'center') return Math.floor(extra / 2); if (verticalAlign === 'bottom') return extra; return 0; } export function readFontWeight( config: Record | undefined | null, ): 'normal' | 'bold' { const raw = config?.fontWeight ?? config?.FontWeight; const v = String(raw ?? 'normal').trim().toLowerCase(); return v === 'bold' || v === '700' || v === 'bolder' ? 'bold' : 'normal'; } export function readFontStyle( config: Record | undefined | null, ): 'normal' | 'italic' { const raw = config?.fontStyle ?? config?.FontStyle; const v = String(raw ?? 'normal').trim().toLowerCase(); return v === 'italic' || v === 'oblique' ? 'italic' : 'normal'; } export function readTextDecoration( config: Record | undefined | null, ): 'none' | 'underline' { const raw = config?.textDecoration ?? config?.TextDecoration; const v = String(raw ?? 'none').trim().toLowerCase(); return v.includes('underline') ? 'underline' : 'none'; } /** 元素级 border(兼容 Border / BorderType) */ export function readElementBorder( el: { border?: string | null; Border?: string | null; BorderType?: string | null; borderType?: string | null }, ): string { const raw = el.border ?? el.Border ?? el.BorderType ?? el.borderType ?? 'none'; return String(raw).trim().toLowerCase() || 'none'; } /** 读取元素 rotation(兼容大小写 / 旧数据) */ export function readElementRotation( el: { rotation?: string | null }, ): 'horizontal' | 'vertical' | 'right' | 'rotate180' { const v = String(el.rotation ?? 'horizontal').trim().toLowerCase(); if (v === 'vertical' || v === 'left' || v === 'rotate-left' || v === 'left90') return 'vertical'; if (v === 'right' || v === 'rotate-right' || v === 'right90') return 'right'; if (v === 'rotate180' || v === '180' || v === 'down' || v === 'inverted') return 'rotate180'; return 'horizontal'; } export function elementRotationDegrees( el: { rotation?: string | null }, ): 0 | -90 | 90 | 180 { const rot = readElementRotation(el); if (rot === 'vertical') return -90; if (rot === 'right') return 90; if (rot === 'rotate180') return 180; return 0; } export function rotateElementLeftValue( el: { rotation?: string | null }, ): 'horizontal' | 'vertical' | 'right' | 'rotate180' { const current = elementRotationDegrees(el); const next = (((current - 90) % 360) + 360) % 360; if (next === 270) return 'vertical'; if (next === 180) return 'rotate180'; if (next === 90) return 'right'; return 'horizontal'; } export function rotateElementRightValue( el: { rotation?: string | null }, ): 'horizontal' | 'vertical' | 'right' | 'rotate180' { const current = elementRotationDegrees(el); const next = (((current + 90) % 360) + 360) % 360; if (next === 270) return 'vertical'; if (next === 180) return 'rotate180'; if (next === 90) return 'right'; return 'horizontal'; } /** 与 App lineHeightForTextElement 一致:leading-tight ≈ 1.25 */ export function textElementLineHeightPx(fontSize: number): number { return Math.max(fontSize + 2, Math.round(fontSize * 1.25)); } /** 矮框(约 1 行高)内的固定文案:预览/打印应单行展示,避免 break-all 折行后第二行被裁切 */ export function isShortSingleLineTextBox( boxHeightPx: number, fontSize: number, text: string, opts?: { breakAll?: boolean }, ): boolean { if (opts?.breakAll) return false; if (String(text ?? '').includes('\n')) return false; const lineH = textElementLineHeightPx(fontSize); const descentPad = Math.max(2, Math.round(fontSize * 0.14)); const maxLines = Math.max( 1, Math.floor((Math.max(0, boxHeightPx) + descentPad) / lineH), ); return maxLines <= 1; } /** 单行文案在 inner 宽度内微缩字号(与 App canvas fit 逻辑一致,供 Web 预览) */ export function fitFontSizeToInnerWidthEstimate( text: string, innerWidthPx: number, fontSize: number, bold = false, ): number { if (!text || innerWidthPx <= 0) return fontSize; const factor = bold ? 0.62 : 0.55; let size = fontSize; for (let i = 0; i < 5; i++) { const w = Math.max(4, String(text).length * size * factor); if (w <= innerWidthPx) return size; size = Math.max(8, Math.floor(size * (innerWidthPx / w) * 0.98)); } return Math.max(8, size); } /** 切换 horizontal / vertical 时交换宽高并保持中心点不变(与参考图 2 一致) */ export function patchElementRotationWithLayout( el: { x: number; y: number; width: number; height: number; rotation?: string | null }, nextRotation: 'horizontal' | 'vertical' | 'right' | 'rotate180', ): { rotation: 'horizontal' | 'vertical' | 'right' | 'rotate180'; x: number; y: number; width: number; height: number; } { const prev = readElementRotation(el); const shouldSwapBox = (rotation: string) => rotation === 'vertical' || rotation === 'right'; if (prev === nextRotation) { // vertical 但宽>高:选框与文字方向不一致,强制纠正 if (shouldSwapBox(nextRotation) && el.width > el.height) { const w = Math.max(1, el.width); const h = Math.max(1, el.height); const cx = el.x + w / 2; const cy = el.y + h / 2; const newW = h; const newH = w; return { rotation: nextRotation, width: newW, height: newH, x: Math.round(cx - newW / 2), y: Math.round(cy - newH / 2), }; } return { rotation: nextRotation, x: el.x, y: el.y, width: el.width, height: el.height, }; } if (!shouldSwapBox(prev) && !shouldSwapBox(nextRotation)) { return { rotation: nextRotation, x: el.x, y: el.y, width: el.width, height: el.height, }; } const w = Math.max(1, el.width); const h = Math.max(1, el.height); const cx = el.x + w / 2; const cy = el.y + h / 2; const newW = h; const newH = w; return { rotation: nextRotation, width: newW, height: newH, x: Math.round(cx - newW / 2), y: Math.round(cy - newH / 2), }; } /** 竖排且宽>高时纠正选框(落库 / 渲染用) */ export function canonicalElementGeometry< T extends { rotation?: string | null; width: number; height: number; x: number; y: number }, >(el: T): T { return normalizeElementRotationBox(el); } /** 旧模板 vertical 但宽>高时自动纠正选框 */ export function normalizeElementRotationBox< T extends { rotation?: string | null; width: number; height: number; x: number; y: number }, >(el: T): T { const rotation = readElementRotation(el); if (rotation !== 'vertical' && rotation !== 'right') return el; if (el.width <= el.height) return el; return { ...el, rotation, ...patchElementRotationWithLayout({ ...el, rotation: 'horizontal' }, rotation), }; }