ste-canvas-poster 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/LICENSE +15 -0
- package/README.md +543 -0
- package/index.d.ts +23 -0
- package/index.js +4 -0
- package/package.json +143 -0
- package/src/measureText.js +53 -0
- package/src/posterAdapter.js +400 -0
- package/src/posterEngine.js +946 -0
- package/src/qrcodeGenerator.js +533 -0
- package/types.d.ts +198 -0
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PosterEngine - 统一海报绘制引擎
|
|
3
|
+
* 版本:v0.0.1
|
|
4
|
+
* 支持平台:微信小程序 / APP
|
|
5
|
+
* 说明:基于 Canvas 2D API,通过声明式 JSON Schema 驱动绘制,双端一致。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { generateQRMatrix } from "./qrcodeGenerator.js";
|
|
9
|
+
|
|
10
|
+
// ─────────────────────────────────────────────
|
|
11
|
+
// 常量
|
|
12
|
+
// ─────────────────────────────────────────────
|
|
13
|
+
const MAX_IMAGE_CACHE_SIZE = 50;
|
|
14
|
+
|
|
15
|
+
// ─────────────────────────────────────────────
|
|
16
|
+
// 工具函数
|
|
17
|
+
// ─────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const LINEAR_GRADIENT_RE = /linear-gradient\(\s*(\d+)deg\s*,\s*(.+)\)/i;
|
|
20
|
+
const TEMPLATE_RE = /\{\{(\w+)\}\}/g;
|
|
21
|
+
|
|
22
|
+
function normalizeImageSrc(src) {
|
|
23
|
+
return src || "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function binarySearchSplit(ctx, text, maxWidth) {
|
|
27
|
+
if (ctx.measureText(text).width <= maxWidth) return text.length;
|
|
28
|
+
let lo = 1;
|
|
29
|
+
let hi = text.length - 1;
|
|
30
|
+
while (lo < hi) {
|
|
31
|
+
const mid = (lo + hi + 1) >> 1;
|
|
32
|
+
if (ctx.measureText(text.substring(0, mid)).width <= maxWidth) {
|
|
33
|
+
lo = mid;
|
|
34
|
+
} else {
|
|
35
|
+
hi = mid - 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return ctx.measureText(text.substring(0, lo)).width <= maxWidth ? lo : 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function binarySearchTruncate(ctx, text, maxWidth, suffix = "...") {
|
|
42
|
+
if (ctx.measureText(text).width + ctx.measureText(suffix).width <= maxWidth) return text;
|
|
43
|
+
const suffixWidth = ctx.measureText(suffix).width;
|
|
44
|
+
const targetWidth = maxWidth - suffixWidth;
|
|
45
|
+
if (targetWidth <= 0) return "";
|
|
46
|
+
let lo = 1;
|
|
47
|
+
let hi = text.length;
|
|
48
|
+
while (lo < hi) {
|
|
49
|
+
const mid = (lo + hi) >> 1;
|
|
50
|
+
if (ctx.measureText(text.substring(0, mid)).width <= targetWidth) {
|
|
51
|
+
lo = mid + 1;
|
|
52
|
+
} else {
|
|
53
|
+
hi = mid;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return text.substring(0, lo - 1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parsePadding(padding) {
|
|
60
|
+
if (padding == null) return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
61
|
+
if (typeof padding === "number")
|
|
62
|
+
return { top: padding, right: padding, bottom: padding, left: padding };
|
|
63
|
+
const [t = 0, r = 0, b = 0, l = 0] = padding;
|
|
64
|
+
if (padding.length === 2) return { top: t, right: r, bottom: t, left: r };
|
|
65
|
+
return { top: t, right: r, bottom: b, left: l };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseBorderRadius(r) {
|
|
69
|
+
if (r == null || r === 0) return [0, 0, 0, 0];
|
|
70
|
+
if (typeof r === "number") return [r, r, r, r];
|
|
71
|
+
const [lt = 0, rt = 0, rb = 0, lb = 0] = r;
|
|
72
|
+
return [lt, rt, rb, lb];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function roundRectPath(ctx, x, y, w, h, radius) {
|
|
76
|
+
const [lt, rt, rb, lb] = parseBorderRadius(radius);
|
|
77
|
+
ctx.beginPath();
|
|
78
|
+
ctx.moveTo(x + lt, y);
|
|
79
|
+
ctx.lineTo(x + w - rt, y);
|
|
80
|
+
ctx.arcTo(x + w, y, x + w, y + rt, rt);
|
|
81
|
+
ctx.lineTo(x + w, y + h - rb);
|
|
82
|
+
ctx.arcTo(x + w, y + h, x + w - rb, y + h, rb);
|
|
83
|
+
ctx.lineTo(x + lb, y + h);
|
|
84
|
+
ctx.arcTo(x, y + h, x, y + h - lb, lb);
|
|
85
|
+
ctx.lineTo(x, y + lt);
|
|
86
|
+
ctx.arcTo(x, y, x + lt, y, lt);
|
|
87
|
+
ctx.closePath();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseLinearGradient(ctx, gradientStr, x, y, w, h) {
|
|
91
|
+
const m = gradientStr.match(LINEAR_GRADIENT_RE);
|
|
92
|
+
if (!m) return null;
|
|
93
|
+
|
|
94
|
+
const deg = parseInt(m[1], 10);
|
|
95
|
+
const rad = ((deg - 90) * Math.PI) / 180;
|
|
96
|
+
const cx = x + w / 2;
|
|
97
|
+
const cy = y + h / 2;
|
|
98
|
+
const halfLen =
|
|
99
|
+
(Math.abs(Math.cos(rad)) * w + Math.abs(Math.sin(rad)) * h) / 2;
|
|
100
|
+
const x0 = cx - Math.cos(rad) * halfLen;
|
|
101
|
+
const y0 = cy - Math.sin(rad) * halfLen;
|
|
102
|
+
const x1 = cx + Math.cos(rad) * halfLen;
|
|
103
|
+
const y1 = cy + Math.sin(rad) * halfLen;
|
|
104
|
+
|
|
105
|
+
const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
|
|
106
|
+
const stops = m[2].split(",").map((s) => s.trim());
|
|
107
|
+
stops.forEach((stop) => {
|
|
108
|
+
const parts = stop.split(/\s+/);
|
|
109
|
+
const color = parts[0];
|
|
110
|
+
const offset = parts[1] ? parseFloat(parts[1]) / 100 : 0;
|
|
111
|
+
gradient.addColorStop(offset, color);
|
|
112
|
+
});
|
|
113
|
+
return gradient;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function calcCover(imgW, imgH, dstX, dstY, dstW, dstH) {
|
|
117
|
+
const scale = Math.max(dstW / imgW, dstH / imgH);
|
|
118
|
+
const sw = dstW / scale;
|
|
119
|
+
const sh = dstH / scale;
|
|
120
|
+
const sx = (imgW - sw) / 2;
|
|
121
|
+
const sy = (imgH - sh) / 2;
|
|
122
|
+
return { sx, sy, sw, sh, dx: dstX, dy: dstY, dw: dstW, dh: dstH };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─────────────────────────────────────────────
|
|
126
|
+
// 图片加载
|
|
127
|
+
// ─────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
export function loadImage(canvas, src) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const normalizedSrc = normalizeImageSrc(src);
|
|
132
|
+
if (!normalizedSrc) {
|
|
133
|
+
reject(new Error(`图片路径为空`));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// #ifdef APP-PLUS
|
|
138
|
+
if (canvas._canvasId) {
|
|
139
|
+
if (/^data:image/i.test(normalizedSrc)) {
|
|
140
|
+
const img = {
|
|
141
|
+
src: normalizedSrc,
|
|
142
|
+
width: 200,
|
|
143
|
+
height: 200,
|
|
144
|
+
path: normalizedSrc,
|
|
145
|
+
};
|
|
146
|
+
resolve(img);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (/^_doc\/uniapp_temp_/i.test(normalizedSrc)) {
|
|
151
|
+
// 临时文件路径,尝试加载
|
|
152
|
+
uni.getImageInfo({
|
|
153
|
+
src: normalizedSrc,
|
|
154
|
+
success: (res) => {
|
|
155
|
+
const img = {
|
|
156
|
+
src: normalizedSrc,
|
|
157
|
+
width: res.width,
|
|
158
|
+
height: res.height,
|
|
159
|
+
path: res.path || normalizedSrc,
|
|
160
|
+
};
|
|
161
|
+
resolve(img);
|
|
162
|
+
},
|
|
163
|
+
fail: (err) => {
|
|
164
|
+
console.error("[PosterEngine] 临时文件加载失败:", err);
|
|
165
|
+
reject(new Error(`图片加载失败: ${err}`));
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
uni.getImageInfo({
|
|
172
|
+
src: normalizedSrc,
|
|
173
|
+
success: (res) => {
|
|
174
|
+
const img = {
|
|
175
|
+
src: normalizedSrc,
|
|
176
|
+
width: res.width,
|
|
177
|
+
height: res.height,
|
|
178
|
+
path: res.path || normalizedSrc,
|
|
179
|
+
};
|
|
180
|
+
resolve(img);
|
|
181
|
+
},
|
|
182
|
+
fail: (err) => {
|
|
183
|
+
console.error("[PosterEngine] 图片加载失败:", err);
|
|
184
|
+
reject(new Error(`图片加载失败: ${err}`));
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// #endif
|
|
190
|
+
|
|
191
|
+
const img = canvas.createImage();
|
|
192
|
+
img.onload = () => resolve(img);
|
|
193
|
+
img.onerror = (e) => {
|
|
194
|
+
console.error("[PosterEngine] 图片加载失败:", e);
|
|
195
|
+
reject(new Error(`图片加载失败: ${e}`));
|
|
196
|
+
};
|
|
197
|
+
img.src = normalizedSrc;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─────────────────────────────────────────────
|
|
202
|
+
// PosterEngine 核心类
|
|
203
|
+
// ─────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
export class PosterEngine {
|
|
206
|
+
constructor({ canvas, schema, data = {}, dpr }) {
|
|
207
|
+
if (!canvas) throw new Error("[PosterEngine] canvas 节点不能为空");
|
|
208
|
+
if (!schema) throw new Error("[PosterEngine] schema 不能为空");
|
|
209
|
+
|
|
210
|
+
this.canvas = canvas;
|
|
211
|
+
this.ctx = canvas.getContext("2d");
|
|
212
|
+
this.schema = schema;
|
|
213
|
+
this.data = data;
|
|
214
|
+
this.dpr = dpr || uni.getSystemInfoSync().pixelRatio || 2;
|
|
215
|
+
this._imgCache = new Map();
|
|
216
|
+
this._tplCache = new Map();
|
|
217
|
+
this._destroyed = false;
|
|
218
|
+
this._logicalWidth = schema.width || 0;
|
|
219
|
+
this._logicalHeight = schema.height || 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─────────────────────────────────────────────
|
|
223
|
+
// 公共 API
|
|
224
|
+
// ─────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
async render() {
|
|
227
|
+
this._checkDestroyed();
|
|
228
|
+
this._tplCache.clear();
|
|
229
|
+
|
|
230
|
+
const {
|
|
231
|
+
width,
|
|
232
|
+
height,
|
|
233
|
+
backgroundImage,
|
|
234
|
+
borderRadius,
|
|
235
|
+
views = [],
|
|
236
|
+
} = this.schema;
|
|
237
|
+
const background = this.schema.background || this.schema.backgroundColor;
|
|
238
|
+
const dpr = this.dpr;
|
|
239
|
+
|
|
240
|
+
this._logicalWidth = width;
|
|
241
|
+
this._logicalHeight = height;
|
|
242
|
+
|
|
243
|
+
// #ifdef MP-WEIXIN
|
|
244
|
+
this.canvas.width = Math.round(width * dpr);
|
|
245
|
+
this.canvas.height = Math.round(height * dpr);
|
|
246
|
+
// #endif
|
|
247
|
+
|
|
248
|
+
// #ifdef APP-PLUS
|
|
249
|
+
this.canvas.width = width;
|
|
250
|
+
this.canvas.height = height;
|
|
251
|
+
// #endif
|
|
252
|
+
|
|
253
|
+
const ctx = this.ctx;
|
|
254
|
+
|
|
255
|
+
// #ifdef MP-WEIXIN
|
|
256
|
+
ctx.scale(dpr, dpr);
|
|
257
|
+
// #endif
|
|
258
|
+
|
|
259
|
+
ctx.save();
|
|
260
|
+
|
|
261
|
+
if (borderRadius) {
|
|
262
|
+
roundRectPath(ctx, 0, 0, width, height, borderRadius);
|
|
263
|
+
ctx.clip();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (backgroundImage) {
|
|
267
|
+
const resolvedBg = this._resolveTemplate(backgroundImage);
|
|
268
|
+
if (resolvedBg) {
|
|
269
|
+
try {
|
|
270
|
+
const img = await this._loadImageCached(resolvedBg);
|
|
271
|
+
const imgSrc = this._getImageSrc(img);
|
|
272
|
+
ctx.drawImage(imgSrc, 0, 0, width, height);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
console.warn("[PosterEngine] 背景图加载失败,使用背景色", e);
|
|
275
|
+
this._fillBackground(background, width, height);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} else if (background) {
|
|
279
|
+
this._fillBackground(background, width, height);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
await this._preloadAllImages(views);
|
|
283
|
+
|
|
284
|
+
for (const node of views) {
|
|
285
|
+
await this._drawNode(node, 0, 0);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
ctx.restore();
|
|
289
|
+
|
|
290
|
+
// #ifdef APP-PLUS
|
|
291
|
+
if (this.canvas._canvasId) {
|
|
292
|
+
ctx.draw();
|
|
293
|
+
}
|
|
294
|
+
// #endif
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
toTempFilePath(options = {}) {
|
|
298
|
+
this._checkDestroyed();
|
|
299
|
+
|
|
300
|
+
const { fileType = "png", quality = 1 } = options;
|
|
301
|
+
const canvasWidth = this.canvas.width;
|
|
302
|
+
const canvasHeight = this.canvas.height;
|
|
303
|
+
const dpr = this.dpr;
|
|
304
|
+
|
|
305
|
+
return new Promise((resolve, reject) => {
|
|
306
|
+
const canvasOptions = {
|
|
307
|
+
fileType,
|
|
308
|
+
quality,
|
|
309
|
+
success: ({ tempFilePath }) => {
|
|
310
|
+
resolve(tempFilePath);
|
|
311
|
+
},
|
|
312
|
+
fail: (err) => {
|
|
313
|
+
reject(err);
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// #ifdef APP-PLUS
|
|
318
|
+
if (this.canvas._canvasId) {
|
|
319
|
+
canvasOptions.canvasId = this.canvas._canvasId;
|
|
320
|
+
if (this.canvas._vm) {
|
|
321
|
+
canvasOptions._this = this.canvas._vm;
|
|
322
|
+
}
|
|
323
|
+
canvasOptions.x = 0;
|
|
324
|
+
canvasOptions.y = 0;
|
|
325
|
+
canvasOptions.width = canvasWidth;
|
|
326
|
+
canvasOptions.height = canvasHeight;
|
|
327
|
+
canvasOptions.destWidth = Math.floor(canvasWidth * dpr);
|
|
328
|
+
canvasOptions.destHeight = Math.floor(canvasHeight * dpr);
|
|
329
|
+
} else {
|
|
330
|
+
// #endif
|
|
331
|
+
canvasOptions.canvas = this.canvas;
|
|
332
|
+
canvasOptions.x = 0;
|
|
333
|
+
canvasOptions.y = 0;
|
|
334
|
+
canvasOptions.width = canvasWidth;
|
|
335
|
+
canvasOptions.height = canvasHeight;
|
|
336
|
+
canvasOptions.destWidth = canvasWidth;
|
|
337
|
+
canvasOptions.destHeight = canvasHeight;
|
|
338
|
+
// #ifdef APP-PLUS
|
|
339
|
+
}
|
|
340
|
+
// #endif
|
|
341
|
+
|
|
342
|
+
uni.canvasToTempFilePath(canvasOptions);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async saveToAlbum() {
|
|
347
|
+
this._checkDestroyed();
|
|
348
|
+
|
|
349
|
+
const tempPath = await this.toTempFilePath();
|
|
350
|
+
return new Promise((resolve, reject) => {
|
|
351
|
+
uni.saveImageToPhotosAlbum({
|
|
352
|
+
filePath: tempPath,
|
|
353
|
+
success: () => resolve(tempPath),
|
|
354
|
+
fail: (err) => {
|
|
355
|
+
if (err.errMsg && err.errMsg.includes("auth deny")) {
|
|
356
|
+
uni.showModal({
|
|
357
|
+
title: "需要相册权限",
|
|
358
|
+
content: "请在设置中开启相册访问权限",
|
|
359
|
+
showCancel: false,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
reject(err);
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
destroy() {
|
|
369
|
+
this._imgCache.clear();
|
|
370
|
+
this._tplCache.clear();
|
|
371
|
+
this.canvas = null;
|
|
372
|
+
this.ctx = null;
|
|
373
|
+
this.schema = null;
|
|
374
|
+
this.data = null;
|
|
375
|
+
this._destroyed = true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
_checkDestroyed() {
|
|
379
|
+
if (this._destroyed) {
|
|
380
|
+
throw new Error("[PosterEngine] 引擎已销毁,请重新创建实例");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ─────────────────────────────────────────────
|
|
385
|
+
// 私有方法:绘制节点
|
|
386
|
+
// ─────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
async _drawNode(
|
|
389
|
+
node,
|
|
390
|
+
offsetX = 0,
|
|
391
|
+
offsetY = 0,
|
|
392
|
+
parentWidth = null,
|
|
393
|
+
parentHeight = null,
|
|
394
|
+
) {
|
|
395
|
+
const { type } = node;
|
|
396
|
+
const css = node.css || {};
|
|
397
|
+
|
|
398
|
+
let resolvedWidth;
|
|
399
|
+
if (css.width != null) {
|
|
400
|
+
resolvedWidth = css.width;
|
|
401
|
+
} else if (parentWidth != null && parentWidth >= 0) {
|
|
402
|
+
resolvedWidth = parentWidth;
|
|
403
|
+
} else if (parentWidth === null) {
|
|
404
|
+
resolvedWidth = this._logicalWidth;
|
|
405
|
+
} else {
|
|
406
|
+
resolvedWidth = 0;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const refWidth = parentWidth != null ? parentWidth : this._logicalWidth;
|
|
410
|
+
const refHeight = parentHeight != null ? parentHeight : this._logicalHeight;
|
|
411
|
+
|
|
412
|
+
let x, y;
|
|
413
|
+
if (css.right != null && css.right >= 0) {
|
|
414
|
+
x = offsetX + refWidth - css.right - resolvedWidth;
|
|
415
|
+
} else {
|
|
416
|
+
x = (css.left || 0) + offsetX;
|
|
417
|
+
}
|
|
418
|
+
if (css.bottom != null && css.bottom >= 0) {
|
|
419
|
+
y = offsetY + refHeight - css.bottom - (css.height || 0);
|
|
420
|
+
} else {
|
|
421
|
+
y = (css.top || 0) + offsetY;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const savedLeft = css.left;
|
|
425
|
+
const savedTop = css.top;
|
|
426
|
+
const savedWidth = css.width;
|
|
427
|
+
css.left = x;
|
|
428
|
+
css.top = y;
|
|
429
|
+
css.width = resolvedWidth;
|
|
430
|
+
|
|
431
|
+
const ctx = this.ctx;
|
|
432
|
+
ctx.save();
|
|
433
|
+
|
|
434
|
+
if (css.opacity != null && css.opacity !== 1) {
|
|
435
|
+
ctx.globalAlpha = css.opacity;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const br = css.borderRadius;
|
|
439
|
+
if (br) {
|
|
440
|
+
roundRectPath(ctx, x, y, resolvedWidth || 0, css.height || 0, br);
|
|
441
|
+
ctx.clip();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
switch (type) {
|
|
445
|
+
case "view":
|
|
446
|
+
await this._drawView(node);
|
|
447
|
+
break;
|
|
448
|
+
case "image":
|
|
449
|
+
await this._drawImage(node);
|
|
450
|
+
break;
|
|
451
|
+
case "text":
|
|
452
|
+
this._drawText(node);
|
|
453
|
+
break;
|
|
454
|
+
case "qrcode":
|
|
455
|
+
await this._drawQRCode(node);
|
|
456
|
+
break;
|
|
457
|
+
default:
|
|
458
|
+
console.warn(`[PosterEngine] 未知元素类型: ${type}`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
ctx.restore();
|
|
462
|
+
css.left = savedLeft;
|
|
463
|
+
css.top = savedTop;
|
|
464
|
+
css.width = savedWidth;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ─────────────────────────────────────────────
|
|
468
|
+
// 各类型绘制方法
|
|
469
|
+
// ─────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
_drawBoxBackground(css) {
|
|
472
|
+
const background = css.background || css.backgroundColor;
|
|
473
|
+
const { left: x, top: y, width: w, height: h } = css;
|
|
474
|
+
const hasBg = !!background;
|
|
475
|
+
const hasBorder = !!(css.borderWidth && css.borderColor);
|
|
476
|
+
if (!hasBg && !hasBorder) return;
|
|
477
|
+
|
|
478
|
+
const ctx = this.ctx;
|
|
479
|
+
const br = css.borderRadius;
|
|
480
|
+
|
|
481
|
+
let fillStyle = null;
|
|
482
|
+
if (hasBg) {
|
|
483
|
+
if (background.includes("linear-gradient")) {
|
|
484
|
+
fillStyle =
|
|
485
|
+
parseLinearGradient(ctx, background, x, y, w || 0, h || 0) ||
|
|
486
|
+
background;
|
|
487
|
+
} else {
|
|
488
|
+
fillStyle = background;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (br) {
|
|
493
|
+
roundRectPath(ctx, x, y, w || 0, h || 0, br);
|
|
494
|
+
if (hasBg) {
|
|
495
|
+
ctx.fillStyle = fillStyle;
|
|
496
|
+
ctx.fill();
|
|
497
|
+
}
|
|
498
|
+
if (hasBorder) {
|
|
499
|
+
ctx.strokeStyle = css.borderColor;
|
|
500
|
+
ctx.lineWidth = css.borderWidth;
|
|
501
|
+
ctx.stroke();
|
|
502
|
+
}
|
|
503
|
+
} else {
|
|
504
|
+
if (hasBg) {
|
|
505
|
+
ctx.fillStyle = fillStyle;
|
|
506
|
+
ctx.fillRect(x, y, w || 0, h || 0);
|
|
507
|
+
}
|
|
508
|
+
if (hasBorder) {
|
|
509
|
+
ctx.strokeStyle = css.borderColor;
|
|
510
|
+
ctx.lineWidth = css.borderWidth;
|
|
511
|
+
ctx.strokeRect(x, y, w || 0, h || 0);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async _drawImage(node) {
|
|
517
|
+
const { src, css } = node;
|
|
518
|
+
const resolvedSrc = this._resolveTemplate(src);
|
|
519
|
+
if (!resolvedSrc) return;
|
|
520
|
+
|
|
521
|
+
const { left: x, top: y, width: w, height: h } = css;
|
|
522
|
+
const objectFit = css.objectFit || "fill";
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
const img = await this._loadImageCached(resolvedSrc);
|
|
526
|
+
const ctx = this.ctx;
|
|
527
|
+
const imgSrc = this._getImageSrc(img);
|
|
528
|
+
|
|
529
|
+
if (objectFit === "cover") {
|
|
530
|
+
const { sx, sy, sw, sh, dx, dy, dw, dh } = calcCover(
|
|
531
|
+
img.width,
|
|
532
|
+
img.height,
|
|
533
|
+
x,
|
|
534
|
+
y,
|
|
535
|
+
w,
|
|
536
|
+
h,
|
|
537
|
+
);
|
|
538
|
+
ctx.drawImage(imgSrc, sx, sy, sw, sh, dx, dy, dw, dh);
|
|
539
|
+
} else if (objectFit === "contain") {
|
|
540
|
+
const scale = Math.min(w / img.width, h / img.height);
|
|
541
|
+
const dw = img.width * scale;
|
|
542
|
+
const dh = img.height * scale;
|
|
543
|
+
const dx = x + (w - dw) / 2;
|
|
544
|
+
const dy = y + (h - dh) / 2;
|
|
545
|
+
ctx.drawImage(imgSrc, dx, dy, dw, dh);
|
|
546
|
+
} else {
|
|
547
|
+
ctx.drawImage(imgSrc, x, y, w, h);
|
|
548
|
+
}
|
|
549
|
+
} catch (e) {
|
|
550
|
+
console.error("[PosterEngine] 图片绘制失败", e);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
_drawText(node) {
|
|
555
|
+
const { text, css } = node;
|
|
556
|
+
const resolvedText = this._resolveTemplate(String(text || ""));
|
|
557
|
+
|
|
558
|
+
const ctx = this.ctx;
|
|
559
|
+
const fontSize = css.fontSize || 14;
|
|
560
|
+
const fontWeight = css.fontWeight || "normal";
|
|
561
|
+
const fontFamily = css.fontFamily || "sans-serif";
|
|
562
|
+
const color = css.color || "#000000";
|
|
563
|
+
const textAlign = css.textAlign || "left";
|
|
564
|
+
const lineHeight = css.lineHeight || 1.4;
|
|
565
|
+
const lines = css.lines || 0;
|
|
566
|
+
const ellipsis = css.ellipsis || false;
|
|
567
|
+
const textDecoration = css.textDecoration || "";
|
|
568
|
+
const textBgColor = css.background || css.backgroundColor;
|
|
569
|
+
|
|
570
|
+
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
571
|
+
ctx.textBaseline = "top";
|
|
572
|
+
|
|
573
|
+
const { left: x, top: y, width: elemWidth, maxWidth: elemMaxWidth } = css;
|
|
574
|
+
const textWidth = elemWidth || elemMaxWidth || this._logicalWidth;
|
|
575
|
+
|
|
576
|
+
let drawX = x;
|
|
577
|
+
if (textAlign === "center") {
|
|
578
|
+
drawX = x + (textWidth || 0) / 2;
|
|
579
|
+
ctx.textAlign = "center";
|
|
580
|
+
} else if (textAlign === "right") {
|
|
581
|
+
drawX = x + (textWidth || 0);
|
|
582
|
+
ctx.textAlign = "right";
|
|
583
|
+
} else {
|
|
584
|
+
ctx.textAlign = "left";
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const lineHeightPx =
|
|
588
|
+
typeof lineHeight === "number" && lineHeight > 10
|
|
589
|
+
? lineHeight
|
|
590
|
+
: fontSize * lineHeight;
|
|
591
|
+
|
|
592
|
+
const textLines = resolvedText.split("\n");
|
|
593
|
+
const allLines = [];
|
|
594
|
+
|
|
595
|
+
if (!textWidth) {
|
|
596
|
+
textLines.forEach((line) => allLines.push(line));
|
|
597
|
+
} else {
|
|
598
|
+
textLines.forEach((segment) => {
|
|
599
|
+
if (segment === "") {
|
|
600
|
+
allLines.push("");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
let currentLine = "";
|
|
604
|
+
for (let i = 0; i < segment.length; ) {
|
|
605
|
+
const remaining = segment.substring(i);
|
|
606
|
+
const fitLen = binarySearchSplit(ctx, remaining, textWidth);
|
|
607
|
+
if (fitLen === 0) {
|
|
608
|
+
if (currentLine) allLines.push(currentLine);
|
|
609
|
+
currentLine = segment[i];
|
|
610
|
+
i++;
|
|
611
|
+
} else if (fitLen >= remaining.length) {
|
|
612
|
+
currentLine += remaining;
|
|
613
|
+
i = segment.length;
|
|
614
|
+
} else {
|
|
615
|
+
allLines.push(currentLine + remaining.substring(0, fitLen));
|
|
616
|
+
currentLine = "";
|
|
617
|
+
i += fitLen;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (currentLine) allLines.push(currentLine);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
let renderLines = allLines;
|
|
625
|
+
if (lines > 0 && allLines.length > lines) {
|
|
626
|
+
renderLines = allLines.slice(0, lines);
|
|
627
|
+
const lastLine = renderLines[renderLines.length - 1];
|
|
628
|
+
renderLines[renderLines.length - 1] = binarySearchTruncate(ctx, lastLine, textWidth) + "...";
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (ellipsis && textWidth) {
|
|
632
|
+
const singleLine = allLines.join("");
|
|
633
|
+
if (ctx.measureText(singleLine).width > textWidth) {
|
|
634
|
+
renderLines = [binarySearchTruncate(ctx, singleLine, textWidth) + "..."];
|
|
635
|
+
} else {
|
|
636
|
+
renderLines = [singleLine];
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (textBgColor) {
|
|
641
|
+
const pd = parsePadding(css.padding);
|
|
642
|
+
ctx.fillStyle = textBgColor;
|
|
643
|
+
const bgX = x;
|
|
644
|
+
const bgY = y;
|
|
645
|
+
const bgW = textWidth || 0;
|
|
646
|
+
const bgH = renderLines.length * lineHeightPx;
|
|
647
|
+
if (css.borderRadius) {
|
|
648
|
+
roundRectPath(
|
|
649
|
+
ctx,
|
|
650
|
+
bgX - pd.left,
|
|
651
|
+
bgY - pd.top,
|
|
652
|
+
bgW + pd.left + pd.right,
|
|
653
|
+
bgH + pd.top + pd.bottom,
|
|
654
|
+
css.borderRadius,
|
|
655
|
+
);
|
|
656
|
+
ctx.fill();
|
|
657
|
+
} else {
|
|
658
|
+
ctx.fillRect(
|
|
659
|
+
bgX - pd.left,
|
|
660
|
+
bgY - pd.top,
|
|
661
|
+
bgW + pd.left + pd.right,
|
|
662
|
+
bgH + pd.top + pd.bottom,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
ctx.fillStyle = color;
|
|
668
|
+
renderLines.forEach((line, i) => {
|
|
669
|
+
const lineY = y + i * lineHeightPx;
|
|
670
|
+
ctx.fillText(line, drawX, lineY);
|
|
671
|
+
|
|
672
|
+
if (textDecoration === "line-through") {
|
|
673
|
+
const lineWidth = ctx.measureText(line).width;
|
|
674
|
+
let lineStartX = drawX;
|
|
675
|
+
if (textAlign === "center") {
|
|
676
|
+
lineStartX = drawX - lineWidth / 2;
|
|
677
|
+
} else if (textAlign === "right") {
|
|
678
|
+
lineStartX = drawX - lineWidth;
|
|
679
|
+
}
|
|
680
|
+
const midY = lineY + fontSize / 2;
|
|
681
|
+
ctx.beginPath();
|
|
682
|
+
ctx.moveTo(lineStartX, midY);
|
|
683
|
+
ctx.lineTo(lineStartX + lineWidth, midY);
|
|
684
|
+
ctx.strokeStyle = color;
|
|
685
|
+
ctx.lineWidth = Math.max(1, fontSize / 16);
|
|
686
|
+
ctx.stroke();
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async _drawView(node) {
|
|
692
|
+
const { css, views: children = [] } = node;
|
|
693
|
+
const { left: x, top: y, width: w, height: h } = css;
|
|
694
|
+
|
|
695
|
+
this._drawBoxBackground(css);
|
|
696
|
+
|
|
697
|
+
if (!children.length) return;
|
|
698
|
+
|
|
699
|
+
const display = css.display;
|
|
700
|
+
if (display === "flex") {
|
|
701
|
+
await this._drawFlexChildren(node);
|
|
702
|
+
} else {
|
|
703
|
+
for (const child of children) {
|
|
704
|
+
await this._drawNode(child, x, y, w, h);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async _drawFlexChildren(node) {
|
|
710
|
+
const { css, views: children = [] } = node;
|
|
711
|
+
const { left: x, top: y, width: w, height: h } = css;
|
|
712
|
+
const flexDirection = css.flexDirection || "row";
|
|
713
|
+
const alignItems = css.alignItems || "flex-start";
|
|
714
|
+
const justifyContent = css.justifyContent || "flex-start";
|
|
715
|
+
const pd = parsePadding(css.padding);
|
|
716
|
+
|
|
717
|
+
const innerX = x + pd.left;
|
|
718
|
+
const innerY = y + pd.top;
|
|
719
|
+
const innerW = w - pd.left - pd.right;
|
|
720
|
+
const innerH = h - pd.top - pd.bottom;
|
|
721
|
+
|
|
722
|
+
const isRow = flexDirection === "row";
|
|
723
|
+
|
|
724
|
+
let totalFixed = 0;
|
|
725
|
+
children.forEach((child) => {
|
|
726
|
+
const childCss = child.css || {};
|
|
727
|
+
const ml = childCss.marginLeft || 0;
|
|
728
|
+
const mr = childCss.marginRight || 0;
|
|
729
|
+
const mt = childCss.marginTop || 0;
|
|
730
|
+
const mb = childCss.marginBottom || 0;
|
|
731
|
+
const childWidth = this._resolveChildWidth(child, innerW);
|
|
732
|
+
if (isRow) {
|
|
733
|
+
totalFixed += childWidth + ml + mr;
|
|
734
|
+
} else {
|
|
735
|
+
totalFixed += (childCss.height || 0) + mt + mb;
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
const gap =
|
|
740
|
+
justifyContent === "space-between" && children.length > 1
|
|
741
|
+
? (isRow ? innerW - totalFixed : innerH - totalFixed) /
|
|
742
|
+
(children.length - 1)
|
|
743
|
+
: justifyContent === "center"
|
|
744
|
+
? isRow
|
|
745
|
+
? (innerW - totalFixed) / 2
|
|
746
|
+
: (innerH - totalFixed) / 2
|
|
747
|
+
: 0;
|
|
748
|
+
|
|
749
|
+
let cursor = isRow ? innerX : innerY;
|
|
750
|
+
|
|
751
|
+
if (justifyContent === "center") {
|
|
752
|
+
cursor += gap;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
for (const child of children) {
|
|
756
|
+
const childCss = child.css || {};
|
|
757
|
+
const ml = childCss.marginLeft || 0;
|
|
758
|
+
const mr = childCss.marginRight || 0;
|
|
759
|
+
const mt = childCss.marginTop || 0;
|
|
760
|
+
const mb = childCss.marginBottom || 0;
|
|
761
|
+
const cw = this._resolveChildWidth(child, innerW);
|
|
762
|
+
const ch = childCss.height || 0;
|
|
763
|
+
|
|
764
|
+
let cx, cy;
|
|
765
|
+
|
|
766
|
+
if (isRow) {
|
|
767
|
+
cx = cursor + ml;
|
|
768
|
+
cy = this._calcAlignOffset(alignItems, innerY, innerH, ch, mt, mb);
|
|
769
|
+
cursor = cx + cw + mr;
|
|
770
|
+
if (justifyContent === "space-between") cursor += gap;
|
|
771
|
+
} else {
|
|
772
|
+
cy = cursor + mt;
|
|
773
|
+
cx = this._calcAlignOffset(alignItems, innerX, innerW, cw, ml, mr);
|
|
774
|
+
cursor = cy + ch + mb;
|
|
775
|
+
if (justifyContent === "space-between") cursor += gap;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const savedLeft = childCss.left;
|
|
779
|
+
const savedTop = childCss.top;
|
|
780
|
+
const savedWidth = childCss.width;
|
|
781
|
+
childCss.left = cx;
|
|
782
|
+
childCss.top = cy;
|
|
783
|
+
childCss.width = cw;
|
|
784
|
+
await this._drawNode(child, 0, 0, cw, ch);
|
|
785
|
+
childCss.left = savedLeft;
|
|
786
|
+
childCss.top = savedTop;
|
|
787
|
+
childCss.width = savedWidth;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
_resolveChildWidth(child, maxWidth = Infinity) {
|
|
792
|
+
const childCss = child.css || {};
|
|
793
|
+
if (childCss.width != null) {
|
|
794
|
+
return childCss.width;
|
|
795
|
+
}
|
|
796
|
+
if (child.type === "text") {
|
|
797
|
+
return this._calcTextWidth(child, maxWidth);
|
|
798
|
+
}
|
|
799
|
+
return 0;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
_calcTextWidth(node, maxWidth = Infinity) {
|
|
803
|
+
const { text, css } = node;
|
|
804
|
+
const resolvedText = this._resolveTemplate(String(text || ""));
|
|
805
|
+
const ctx = this.ctx;
|
|
806
|
+
const fontSize = css.fontSize || 14;
|
|
807
|
+
const fontWeight = css.fontWeight || "normal";
|
|
808
|
+
const fontFamily = css.fontFamily || "sans-serif";
|
|
809
|
+
const effectiveMaxWidth =
|
|
810
|
+
maxWidth != null && !isNaN(maxWidth) ? maxWidth : Infinity;
|
|
811
|
+
const textMaxWidth = css.maxWidth || effectiveMaxWidth;
|
|
812
|
+
|
|
813
|
+
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
814
|
+
const textWidth = ctx.measureText(resolvedText).width;
|
|
815
|
+
return Math.min(textWidth, textMaxWidth);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
_calcAlignOffset(align, start, size, childSize, marginStart, marginEnd) {
|
|
819
|
+
switch (align) {
|
|
820
|
+
case "center":
|
|
821
|
+
return start + (size - childSize) / 2;
|
|
822
|
+
case "flex-end":
|
|
823
|
+
return start + size - childSize - marginEnd;
|
|
824
|
+
default:
|
|
825
|
+
return start + marginStart;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async _drawQRCode(node) {
|
|
830
|
+
const qrText = node.text || node.src || "";
|
|
831
|
+
const resolvedText = this._resolveTemplate(qrText);
|
|
832
|
+
if (!resolvedText) return;
|
|
833
|
+
|
|
834
|
+
const { css } = node;
|
|
835
|
+
const { left: x, top: y, width: w, height: h } = css;
|
|
836
|
+
const bgColor = css.background || css.backgroundColor || "#FFFFFF";
|
|
837
|
+
const qrColor = css.color || "#000000";
|
|
838
|
+
|
|
839
|
+
try {
|
|
840
|
+
const matrix = generateQRMatrix(resolvedText);
|
|
841
|
+
const moduleCount = matrix.length;
|
|
842
|
+
const margin = 2;
|
|
843
|
+
const totalModules = moduleCount + margin * 2;
|
|
844
|
+
const moduleSize = w / totalModules;
|
|
845
|
+
|
|
846
|
+
const ctx = this.ctx;
|
|
847
|
+
|
|
848
|
+
ctx.fillStyle = bgColor;
|
|
849
|
+
ctx.fillRect(x, y, w, h);
|
|
850
|
+
|
|
851
|
+
ctx.fillStyle = qrColor;
|
|
852
|
+
for (let r = 0; r < moduleCount; r++) {
|
|
853
|
+
for (let c = 0; c < moduleCount; c++) {
|
|
854
|
+
if (matrix[r][c] === 1) {
|
|
855
|
+
ctx.fillRect(
|
|
856
|
+
x + (c + margin) * moduleSize,
|
|
857
|
+
y + (r + margin) * moduleSize,
|
|
858
|
+
moduleSize,
|
|
859
|
+
moduleSize,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
} catch (e) {
|
|
865
|
+
console.error("[PosterEngine] 二维码生成失败", e);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ─────────────────────────────────────────────
|
|
870
|
+
// 私有工具
|
|
871
|
+
// ─────────────────────────────────────────────
|
|
872
|
+
|
|
873
|
+
_getImageSrc(img) {
|
|
874
|
+
// #ifdef APP-PLUS
|
|
875
|
+
return img.path || img;
|
|
876
|
+
// #endif
|
|
877
|
+
// #ifdef MP-WEIXIN
|
|
878
|
+
return img;
|
|
879
|
+
// #endif
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async _loadImageCached(src) {
|
|
883
|
+
if (this._imgCache.has(src)) {
|
|
884
|
+
return this._imgCache.get(src);
|
|
885
|
+
}
|
|
886
|
+
const img = await loadImage(this.canvas, src);
|
|
887
|
+
|
|
888
|
+
if (this._imgCache.size >= MAX_IMAGE_CACHE_SIZE) {
|
|
889
|
+
const firstKey = this._imgCache.keys().next().value;
|
|
890
|
+
this._imgCache.delete(firstKey);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
this._imgCache.set(src, img);
|
|
894
|
+
return img;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async _preloadAllImages(views) {
|
|
898
|
+
const promises = [];
|
|
899
|
+
const collect = (nodes) => {
|
|
900
|
+
for (const node of nodes) {
|
|
901
|
+
if (node.type === "image" && node.src) {
|
|
902
|
+
const src = this._resolveTemplate(node.src);
|
|
903
|
+
if (src && !this._imgCache.has(src)) {
|
|
904
|
+
promises.push(
|
|
905
|
+
this._loadImageCached(src).catch((e) => {
|
|
906
|
+
console.warn("[PosterEngine] 图片预加载失败:", e);
|
|
907
|
+
}),
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (node.views && node.views.length) {
|
|
912
|
+
collect(node.views);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
collect(views);
|
|
918
|
+
|
|
919
|
+
if (promises.length > 0) {
|
|
920
|
+
await Promise.all(promises);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
_resolveTemplate(str) {
|
|
925
|
+
if (typeof str !== "string") return str;
|
|
926
|
+
const cached = this._tplCache.get(str);
|
|
927
|
+
if (cached !== undefined) return cached;
|
|
928
|
+
const result = str.replace(TEMPLATE_RE, (_, key) => {
|
|
929
|
+
const val = this.data[key];
|
|
930
|
+
return val != null ? String(val) : `{{${key}}}`;
|
|
931
|
+
});
|
|
932
|
+
this._tplCache.set(str, result);
|
|
933
|
+
return result;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
_fillBackground(color, width, height) {
|
|
937
|
+
const ctx = this.ctx;
|
|
938
|
+
if (color && color.includes("linear-gradient")) {
|
|
939
|
+
const grad = parseLinearGradient(ctx, color, 0, 0, width, height);
|
|
940
|
+
ctx.fillStyle = grad || "#FFFFFF";
|
|
941
|
+
} else {
|
|
942
|
+
ctx.fillStyle = color;
|
|
943
|
+
}
|
|
944
|
+
ctx.fillRect(0, 0, width, height);
|
|
945
|
+
}
|
|
946
|
+
}
|