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/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
+ }