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
package/package.json
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "ste-canvas-poster",
|
|
3
|
+
"displayName": "ste-canvas-poster",
|
|
4
|
+
"name": "ste-canvas-poster",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "基于 Canvas 2D API 的声明式海报绘制引擎,支持微信小程序与 APP 双端",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "index.js",
|
|
9
|
+
"types": "index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./index.d.ts",
|
|
13
|
+
"import": "./index.js"
|
|
14
|
+
},
|
|
15
|
+
"./types": {
|
|
16
|
+
"types": "./types.d.ts"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"canvas",
|
|
21
|
+
"poster",
|
|
22
|
+
"uni-app",
|
|
23
|
+
"wechat-miniprogram",
|
|
24
|
+
"schema-driven",
|
|
25
|
+
"qrcode",
|
|
26
|
+
"flex-layout",
|
|
27
|
+
"canvas-poster",
|
|
28
|
+
"declarative"
|
|
29
|
+
],
|
|
30
|
+
"repository": "https://github.com/wuhanshuzhiyun/ste-canvas-poster",
|
|
31
|
+
"engines": {
|
|
32
|
+
"HBuilderX": "^3.1.0",
|
|
33
|
+
"uni-app": "^3.1.0",
|
|
34
|
+
"uni-app-x": "^3.1.0"
|
|
35
|
+
},
|
|
36
|
+
"dcloudext": {
|
|
37
|
+
"type": "sdk",
|
|
38
|
+
"sale": {
|
|
39
|
+
"regular": {
|
|
40
|
+
"price": "0.00"
|
|
41
|
+
},
|
|
42
|
+
"sourcecode": {
|
|
43
|
+
"price": "0.00"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"contact": {
|
|
47
|
+
"qq": ""
|
|
48
|
+
},
|
|
49
|
+
"declaration": {
|
|
50
|
+
"ads": "",
|
|
51
|
+
"data": "",
|
|
52
|
+
"permissions": ""
|
|
53
|
+
},
|
|
54
|
+
"npmurl": "https://www.npmjs.com/package/ste-canvas-poster",
|
|
55
|
+
"darkmode": "-",
|
|
56
|
+
"i18n": "-",
|
|
57
|
+
"widescreen": "-"
|
|
58
|
+
},
|
|
59
|
+
"uni_modules": {
|
|
60
|
+
"dependencies": [],
|
|
61
|
+
"encrypt": [],
|
|
62
|
+
"platforms": {
|
|
63
|
+
"cloud": {
|
|
64
|
+
"tcb": "n",
|
|
65
|
+
"aliyun": "n",
|
|
66
|
+
"alipay": "n"
|
|
67
|
+
},
|
|
68
|
+
"client": {
|
|
69
|
+
"uni-app": {
|
|
70
|
+
"vue": {
|
|
71
|
+
"vue2": "y",
|
|
72
|
+
"vue3": "y"
|
|
73
|
+
},
|
|
74
|
+
"web": {
|
|
75
|
+
"safari": "n",
|
|
76
|
+
"chrome": "n"
|
|
77
|
+
},
|
|
78
|
+
"app": {
|
|
79
|
+
"vue": "y",
|
|
80
|
+
"nvue": "n",
|
|
81
|
+
"android": "y",
|
|
82
|
+
"ios": "y",
|
|
83
|
+
"harmony": "-"
|
|
84
|
+
},
|
|
85
|
+
"mp": {
|
|
86
|
+
"weixin": "y",
|
|
87
|
+
"alipay": "n",
|
|
88
|
+
"toutiao": "n",
|
|
89
|
+
"baidu": "n",
|
|
90
|
+
"kuaishou": "n",
|
|
91
|
+
"jd": "n",
|
|
92
|
+
"harmony": "n",
|
|
93
|
+
"qq": "n",
|
|
94
|
+
"lark": "n",
|
|
95
|
+
"xhs": "n"
|
|
96
|
+
},
|
|
97
|
+
"quickapp": {
|
|
98
|
+
"huawei": "n",
|
|
99
|
+
"union": "n"
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"uni-app-x": {
|
|
103
|
+
"web": {
|
|
104
|
+
"safari": "n",
|
|
105
|
+
"chrome": "n"
|
|
106
|
+
},
|
|
107
|
+
"app": {
|
|
108
|
+
"android": "-",
|
|
109
|
+
"ios": "-",
|
|
110
|
+
"harmony": "-"
|
|
111
|
+
},
|
|
112
|
+
"mp": {
|
|
113
|
+
"weixin": "-"
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
"sideEffects": false,
|
|
120
|
+
"files": [
|
|
121
|
+
"index.js",
|
|
122
|
+
"index.d.ts",
|
|
123
|
+
"types.d.ts",
|
|
124
|
+
"CHANGELOG.md",
|
|
125
|
+
"README.md",
|
|
126
|
+
"LICENSE",
|
|
127
|
+
"src/measureText.js",
|
|
128
|
+
"src/posterAdapter.js",
|
|
129
|
+
"src/posterEngine.js",
|
|
130
|
+
"src/qrcodeGenerator.js"
|
|
131
|
+
],
|
|
132
|
+
"license": "ISC",
|
|
133
|
+
"author": {
|
|
134
|
+
"name": "yajun.xu"
|
|
135
|
+
},
|
|
136
|
+
"bugs": {
|
|
137
|
+
"url": "https://github.com/wuhanshuzhiyun/ste-canvas-poster/issues"
|
|
138
|
+
},
|
|
139
|
+
"homepage": "https://github.com/wuhanshuzhiyun/ste-canvas-poster#readme",
|
|
140
|
+
"publishConfig": {
|
|
141
|
+
"access": "public"
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const NARROW_CHARS = new Set([
|
|
2
|
+
0x69, 0x6c, 0x6a, 0x74, 0x66, 0x72, 0x2e, 0x2c, 0x3b, 0x3a, 0x21, 0x7c, 0x27, 0x60, 0xb4, 0x5e, 0x7e, 0x28, 0x29,
|
|
3
|
+
0x5b, 0x5d, 0x7b, 0x7d, 0x2f, 0x5c, 0x2d, 0x5f, 0x31,
|
|
4
|
+
]);
|
|
5
|
+
|
|
6
|
+
const WIDE_CHARS = new Set([
|
|
7
|
+
0x57, 0x4d, 0x40, 0x25, 0x26, 0x6d, 0x77, 0x4f, 0x51, 0x44, 0x48, 0x47, 0x4e, 0x52, 0x55, 0x56,
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
function isFullWidth(code) {
|
|
11
|
+
return (
|
|
12
|
+
(code >= 0x4e00 && code <= 0x9fff) ||
|
|
13
|
+
(code >= 0x3400 && code <= 0x4dbf) ||
|
|
14
|
+
(code >= 0x3000 && code <= 0x303f) ||
|
|
15
|
+
(code >= 0xff01 && code <= 0xff60) ||
|
|
16
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
17
|
+
(code >= 0x2e80 && code <= 0x2fdf) ||
|
|
18
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
19
|
+
(code >= 0x2f800 && code <= 0x2fa1f) ||
|
|
20
|
+
(code >= 0xac00 && code <= 0xd7af) ||
|
|
21
|
+
(code >= 0x3040 && code <= 0x309f) ||
|
|
22
|
+
(code >= 0x30a0 && code <= 0x30ff) ||
|
|
23
|
+
(code >= 0xa000 && code <= 0xa48f) ||
|
|
24
|
+
(code >= 0xa490 && code <= 0xa4cf)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getCharWidthRatio(code) {
|
|
29
|
+
if (isFullWidth(code)) return 1.0;
|
|
30
|
+
if (NARROW_CHARS.has(code)) return 0.33;
|
|
31
|
+
if (WIDE_CHARS.has(code)) return 0.78;
|
|
32
|
+
if (code >= 0x30 && code <= 0x39) return 0.55;
|
|
33
|
+
if ((code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a)) return 0.58;
|
|
34
|
+
if (code >= 0xc0 && code <= 0x24f) return 0.6;
|
|
35
|
+
if (code >= 0x2018 && code <= 0x201d) return 0.55;
|
|
36
|
+
if (code === 0x3000) return 1.0;
|
|
37
|
+
if (code === 0x200b || code === 0xfeff) return 0;
|
|
38
|
+
return 0.55;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 计算文本宽度
|
|
43
|
+
*/
|
|
44
|
+
export function measureText(text, fontSize, bold = false) {
|
|
45
|
+
if (!text || typeof text !== 'string') return 0;
|
|
46
|
+
let width = 0;
|
|
47
|
+
for (const char of text) {
|
|
48
|
+
const code = char.codePointAt(0);
|
|
49
|
+
width += getCharWidthRatio(code) * fontSize;
|
|
50
|
+
}
|
|
51
|
+
if (bold) width *= 1.06;
|
|
52
|
+
return Math.ceil(width);
|
|
53
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* posterAdapter.js - 海报引擎双端适配层
|
|
3
|
+
* 版本:v0.0.1
|
|
4
|
+
*
|
|
5
|
+
* 功能:
|
|
6
|
+
* 1. 兼容微信小程序 / APP 两端获取 Canvas 2D 节点
|
|
7
|
+
* 2. 封装 renderPoster 高层 API
|
|
8
|
+
* 3. 自动处理图片路径解析(相对路径 → 完整 URL)
|
|
9
|
+
* 4. 自动处理 APP 端图片下载本地化
|
|
10
|
+
*
|
|
11
|
+
* 使用方式(业务层无需关心平台差异):
|
|
12
|
+
* const engine = await renderPoster({ schema, data, selector: '#myCanvas', vm: this });
|
|
13
|
+
* await engine.saveToAlbum();
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { PosterEngine } from "./posterEngine.js";
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────
|
|
19
|
+
// 工具函数
|
|
20
|
+
// ─────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 判断是否为完整 URL
|
|
24
|
+
* @param {string} src
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
function isFullUrl(src) {
|
|
28
|
+
if (!src) return false;
|
|
29
|
+
return (
|
|
30
|
+
/^https?:\/\//i.test(src) ||
|
|
31
|
+
/^data:image/i.test(src) ||
|
|
32
|
+
/^wxfile:/i.test(src)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 解析图片路径(相对路径 → 完整 URL)
|
|
38
|
+
* @param {string} src
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function resolveImagePath(src) {
|
|
42
|
+
if (!src) return "";
|
|
43
|
+
if (isFullUrl(src)) return src;
|
|
44
|
+
// #ifdef APP-PLUS
|
|
45
|
+
if (/^(file:|\/var\/|\/storage\/)/i.test(src)) return src;
|
|
46
|
+
// #endif
|
|
47
|
+
return src;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 深度解析数据对象中的所有图片路径
|
|
52
|
+
* @param {Object} data
|
|
53
|
+
* @returns {Object}
|
|
54
|
+
*/
|
|
55
|
+
const IMAGE_KEY_SUFFIX_RE = /(Image|Img|Url|Src|Photo|Pic)$/i;
|
|
56
|
+
const IMAGE_KEY_EXACT_RE = /^(background|qrcode|cover|avatar)$/i;
|
|
57
|
+
|
|
58
|
+
function resolveDataImages(data) {
|
|
59
|
+
if (!data || typeof data !== "object") return data;
|
|
60
|
+
|
|
61
|
+
let resolved = null;
|
|
62
|
+
for (const [key, value] of Object.entries(data)) {
|
|
63
|
+
if (
|
|
64
|
+
typeof value === "string" &&
|
|
65
|
+
(IMAGE_KEY_SUFFIX_RE.test(key) || IMAGE_KEY_EXACT_RE.test(key))
|
|
66
|
+
) {
|
|
67
|
+
const newPath = resolveImagePath(value);
|
|
68
|
+
if (newPath !== value) {
|
|
69
|
+
if (!resolved) {
|
|
70
|
+
resolved = { ...data };
|
|
71
|
+
}
|
|
72
|
+
resolved[key] = newPath;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return resolved || data;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─────────────────────────────────────────────
|
|
80
|
+
// 辅助:rpx → px 转换
|
|
81
|
+
// ─────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
let _windowWidth = null;
|
|
84
|
+
|
|
85
|
+
function getWindowWidth() {
|
|
86
|
+
const current = uni.getSystemInfoSync().windowWidth;
|
|
87
|
+
_windowWidth = current;
|
|
88
|
+
return _windowWidth;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function rpx2px(rpx) {
|
|
92
|
+
if (_windowWidth == null) {
|
|
93
|
+
_windowWidth = getWindowWidth();
|
|
94
|
+
}
|
|
95
|
+
return (rpx * _windowWidth) / 750;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function px2rpx(px) {
|
|
99
|
+
if (_windowWidth == null) {
|
|
100
|
+
_windowWidth = getWindowWidth();
|
|
101
|
+
}
|
|
102
|
+
return (px * 750) / _windowWidth;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 将 Schema 中的 rpx 值转换为 px
|
|
107
|
+
* 业务层可以直接使用 rpx(如 750rpx),插件自动转换为当前屏幕的 px 值
|
|
108
|
+
* @param {Object} schema - 原始 schema(包含 rpx 值)
|
|
109
|
+
* @returns {Object} - 转换后的 schema(所有 rpx 转为 px)
|
|
110
|
+
*/
|
|
111
|
+
const NON_DIMENSION_KEYS = new Set([
|
|
112
|
+
"opacity",
|
|
113
|
+
"lines",
|
|
114
|
+
"flex",
|
|
115
|
+
"lineHeight",
|
|
116
|
+
"zIndex",
|
|
117
|
+
"dpr",
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
function shouldTransform(val) {
|
|
121
|
+
if (typeof val === "number") return true;
|
|
122
|
+
if (typeof val === "string" && /^-?\d+(\.\d+)?$/.test(val)) return true;
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function transformValue(val, scale) {
|
|
127
|
+
if (typeof val === "number") return Math.floor(val * scale);
|
|
128
|
+
if (typeof val === "string" && /^-?\d+(\.\d+)?$/.test(val)) {
|
|
129
|
+
return Math.floor(parseFloat(val) * scale);
|
|
130
|
+
}
|
|
131
|
+
return val;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function transformSchemaRpx(schema) {
|
|
135
|
+
if (!schema || typeof schema !== "object") return schema;
|
|
136
|
+
if (_windowWidth == null) {
|
|
137
|
+
_windowWidth = getWindowWidth() || 375;
|
|
138
|
+
}
|
|
139
|
+
const scale = _windowWidth / 750;
|
|
140
|
+
|
|
141
|
+
function traverseInPlace(obj) {
|
|
142
|
+
if (Array.isArray(obj)) {
|
|
143
|
+
for (let i = 0; i < obj.length; i++) {
|
|
144
|
+
traverseInPlace(obj[i]);
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!obj || typeof obj !== "object") return;
|
|
149
|
+
for (const key of Object.keys(obj)) {
|
|
150
|
+
const value = obj[key];
|
|
151
|
+
if (value && typeof value === "object") {
|
|
152
|
+
traverseInPlace(value);
|
|
153
|
+
} else if (shouldTransform(value)) {
|
|
154
|
+
if (NON_DIMENSION_KEYS.has(key) || (key === "fontWeight" && typeof value === "number")) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
obj[key] = transformValue(value, scale);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
traverseInPlace(schema);
|
|
163
|
+
return schema;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─────────────────────────────────────────────
|
|
167
|
+
// 辅助:APP 端图片预下载
|
|
168
|
+
// ─────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function downloadImageToLocal(url) {
|
|
171
|
+
if (!url) return Promise.resolve("");
|
|
172
|
+
if (/^wxfile:/i.test(url)) return Promise.resolve(url);
|
|
173
|
+
if (/^data:image/i.test(url)) return Promise.resolve(url);
|
|
174
|
+
// #ifdef APP-PLUS
|
|
175
|
+
if (/^(file:|\/var\/|\/storage\/)/i.test(url)) return Promise.resolve(url);
|
|
176
|
+
// #endif
|
|
177
|
+
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
uni.downloadFile({
|
|
180
|
+
url,
|
|
181
|
+
success: ({ tempFilePath, statusCode }) => {
|
|
182
|
+
if (statusCode >= 200 && statusCode < 300 && tempFilePath) {
|
|
183
|
+
resolve(tempFilePath);
|
|
184
|
+
} else {
|
|
185
|
+
console.error("[posterAdapter] 图片下载失败,状态码:", statusCode);
|
|
186
|
+
reject(new Error(`下载失败,状态码: ${statusCode}`));
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
fail: (err) => {
|
|
190
|
+
console.error("[posterAdapter] 图片下载失败", err);
|
|
191
|
+
reject(err);
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 批量将 Schema 中的网络图片预下载到本地(APP 端专用)
|
|
199
|
+
* @param {Object} schema
|
|
200
|
+
* @param {Object} data 已解析过的数据
|
|
201
|
+
* @returns {Promise<Object>} 新的 data 对象,图片字段替换为本地路径
|
|
202
|
+
*/
|
|
203
|
+
async function preloadSchemaImages(schema, data = {}) {
|
|
204
|
+
// #ifdef MP-WEIXIN
|
|
205
|
+
return data;
|
|
206
|
+
// #endif
|
|
207
|
+
|
|
208
|
+
// #ifdef APP-PLUS
|
|
209
|
+
const newData = { ...data };
|
|
210
|
+
const promises = [];
|
|
211
|
+
|
|
212
|
+
// 判断是否需要下载:网络图片(http/https)需要下载,其他类型不需要
|
|
213
|
+
function shouldDownload(url) {
|
|
214
|
+
if (!url) return false;
|
|
215
|
+
return /^https?:\/\//.test(url);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function traverse(views = []) {
|
|
219
|
+
for (const node of views) {
|
|
220
|
+
if (node.type === "image" && node.src) {
|
|
221
|
+
const tplMatch = node.src.match(/\{\{(\w+)\}\}/);
|
|
222
|
+
if (tplMatch) {
|
|
223
|
+
const key = tplMatch[1];
|
|
224
|
+
const value = newData[key];
|
|
225
|
+
if (shouldDownload(value)) {
|
|
226
|
+
promises.push(
|
|
227
|
+
downloadImageToLocal(value)
|
|
228
|
+
.then((localPath) => {
|
|
229
|
+
newData[key] = localPath;
|
|
230
|
+
})
|
|
231
|
+
.catch((err) => {
|
|
232
|
+
console.warn(
|
|
233
|
+
"[posterAdapter] 图片预下载失败,保留原值:",
|
|
234
|
+
err,
|
|
235
|
+
);
|
|
236
|
+
// 下载失败保留原值
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (node.views?.length) {
|
|
243
|
+
traverse(node.views);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
traverse(schema.views || []);
|
|
249
|
+
|
|
250
|
+
if (schema.backgroundImage) {
|
|
251
|
+
const bgKey = (schema.backgroundImage.match(/\{\{(\w+)\}\}/) || [])[1];
|
|
252
|
+
if (bgKey && shouldDownload(newData[bgKey])) {
|
|
253
|
+
promises.push(
|
|
254
|
+
downloadImageToLocal(newData[bgKey])
|
|
255
|
+
.then((localPath) => {
|
|
256
|
+
newData[bgKey] = localPath;
|
|
257
|
+
})
|
|
258
|
+
.catch((err) => {
|
|
259
|
+
console.warn("[posterAdapter] 背景图预下载失败,保留原值:", err);
|
|
260
|
+
// 下载失败保留原值
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await Promise.all(promises);
|
|
267
|
+
return newData;
|
|
268
|
+
// #endif
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ─────────────────────────────────────────────
|
|
272
|
+
// 获取 Canvas 节点
|
|
273
|
+
// ─────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
export function getCanvasNode(selector, vm) {
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
// #ifdef APP-PLUS
|
|
278
|
+
const canvasId = selector.replace("#", "");
|
|
279
|
+
const ctx = uni.createCanvasContext(canvasId, vm);
|
|
280
|
+
if (ctx) {
|
|
281
|
+
const sysInfo = uni.getSystemInfoSync();
|
|
282
|
+
const screenWidth = sysInfo.screenWidth || sysInfo.windowWidth || 375;
|
|
283
|
+
const rpxToPx = screenWidth / 750;
|
|
284
|
+
const defaultWidth = Math.floor(750 * rpxToPx);
|
|
285
|
+
const defaultHeight = Math.floor(1068 * rpxToPx);
|
|
286
|
+
|
|
287
|
+
const mockCanvas = {
|
|
288
|
+
width: defaultWidth,
|
|
289
|
+
height: defaultHeight,
|
|
290
|
+
getContext: (type) => (type === "2d" ? ctx : null),
|
|
291
|
+
createImage: () => {
|
|
292
|
+
// 构造一个与小程序端 canvas.createImage() 行为一致的图片对象
|
|
293
|
+
// 当 src 被赋值时,自动调用 uni.getImageInfo 加载图片尺寸并触发 onload
|
|
294
|
+
const img = {
|
|
295
|
+
width: 0,
|
|
296
|
+
height: 0,
|
|
297
|
+
onload: null,
|
|
298
|
+
onerror: null,
|
|
299
|
+
};
|
|
300
|
+
let _src = "";
|
|
301
|
+
Object.defineProperty(img, "src", {
|
|
302
|
+
get() {
|
|
303
|
+
return _src;
|
|
304
|
+
},
|
|
305
|
+
set(val) {
|
|
306
|
+
_src = val;
|
|
307
|
+
if (!val) return;
|
|
308
|
+
uni.getImageInfo({
|
|
309
|
+
src: val,
|
|
310
|
+
success: (res) => {
|
|
311
|
+
img.width = res.width;
|
|
312
|
+
img.height = res.height;
|
|
313
|
+
if (img.onload) img.onload();
|
|
314
|
+
},
|
|
315
|
+
fail: (err) => {
|
|
316
|
+
if (img.onerror) img.onerror(err);
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
enumerable: true,
|
|
321
|
+
configurable: true,
|
|
322
|
+
});
|
|
323
|
+
return img;
|
|
324
|
+
},
|
|
325
|
+
_canvasId: canvasId,
|
|
326
|
+
_vm: vm,
|
|
327
|
+
};
|
|
328
|
+
resolve(mockCanvas);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// #endif
|
|
332
|
+
|
|
333
|
+
// #ifdef MP-WEIXIN
|
|
334
|
+
const query = uni.createSelectorQuery().in(vm);
|
|
335
|
+
query
|
|
336
|
+
.select(selector)
|
|
337
|
+
.node((res) => {
|
|
338
|
+
const node = res && res.node ? res.node : res;
|
|
339
|
+
if (node) {
|
|
340
|
+
resolve(node);
|
|
341
|
+
} else {
|
|
342
|
+
reject(new Error(`[posterAdapter] 未找到 Canvas 节点: ${selector}`));
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
.exec();
|
|
346
|
+
// #endif
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─────────────────────────────────────────────
|
|
351
|
+
// 高层 API
|
|
352
|
+
// ─────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 渲染海报(一步到位,自动处理路径解析和平台差异)
|
|
356
|
+
*
|
|
357
|
+
* @param {Object} options
|
|
358
|
+
* @param {Object} options.schema JSON Schema 对象(数值可直接使用 rpx)
|
|
359
|
+
* @param {Object} [options.data] 模板变量数据(图片路径可以是相对路径)
|
|
360
|
+
* @param {string} options.selector Canvas 选择器(如 '#posterCanvas')
|
|
361
|
+
* @param {Object} options.vm Vue 组件实例(this)
|
|
362
|
+
* @param {number} [options.dpr] 像素比(可选,默认自动获取)
|
|
363
|
+
* @param {boolean} [options.useRpx] 是否将 schema 中的数值视为 rpx 并自动转换(默认 true)
|
|
364
|
+
* @returns {Promise<PosterEngine>} 返回引擎实例,供后续 save/share 使用
|
|
365
|
+
*/
|
|
366
|
+
export async function renderPoster({
|
|
367
|
+
schema,
|
|
368
|
+
data = {},
|
|
369
|
+
selector,
|
|
370
|
+
vm,
|
|
371
|
+
dpr,
|
|
372
|
+
useRpx = true,
|
|
373
|
+
}) {
|
|
374
|
+
if (!selector) throw new Error("[posterAdapter] selector 不能为空");
|
|
375
|
+
if (!vm) throw new Error("[posterAdapter] vm 不能为空");
|
|
376
|
+
|
|
377
|
+
// 深拷贝 schema,避免修改用户传入的原始对象
|
|
378
|
+
const schemaCopy = JSON.parse(JSON.stringify(schema));
|
|
379
|
+
|
|
380
|
+
// 自动将 schema 中的 rpx 转换为 px(业务层可以直接使用设计稿的 rpx 值)
|
|
381
|
+
const transformedSchema = useRpx ? transformSchemaRpx(schemaCopy) : schemaCopy;
|
|
382
|
+
|
|
383
|
+
// 自动解析数据中的图片路径(相对路径 → 完整 URL)
|
|
384
|
+
const resolvedData = resolveDataImages(data);
|
|
385
|
+
let preloadedData = resolvedData;
|
|
386
|
+
|
|
387
|
+
// #ifdef APP-PLUS
|
|
388
|
+
preloadedData = await preloadSchemaImages(transformedSchema, resolvedData);
|
|
389
|
+
// #endif
|
|
390
|
+
|
|
391
|
+
const canvas = await getCanvasNode(selector, vm);
|
|
392
|
+
const engine = new PosterEngine({
|
|
393
|
+
canvas,
|
|
394
|
+
schema: transformedSchema,
|
|
395
|
+
data: preloadedData,
|
|
396
|
+
dpr,
|
|
397
|
+
});
|
|
398
|
+
await engine.render();
|
|
399
|
+
return engine;
|
|
400
|
+
}
|