textElementLayout.ts
7.92 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
/** 文本控件在元素框内的垂直对齐(config.verticalAlign) */
export type TextVerticalAlign = 'top' | 'center' | 'bottom';
export function readVerticalAlign(
config: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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),
};
}