koishi-plugin-ll-bns-fortune 0.2.3 → 0.4.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/assets/bg-panel.png +0 -0
- package/lib/data.js +22 -10
- package/lib/index.js +37 -13
- package/lib/render.js +281 -22
- package/package.json +1 -1
- package/readme.md +8 -1
- package/assets/bg.png +0 -0
|
Binary file
|
package/lib/data.js
CHANGED
|
@@ -90,26 +90,38 @@ export const DEFAULT_MAPS = [
|
|
|
90
90
|
/**
|
|
91
91
|
* 排版模板:{xxx} 会被替换成实际内容。
|
|
92
92
|
*
|
|
93
|
-
*
|
|
93
|
+
* 行首可以加的标记(可以叠加,顺序随意):
|
|
94
94
|
* [left] / [center] 指定这一行的对齐方式(默认跟随 panel.align)
|
|
95
95
|
* [size=0.85] 指定这一行的字号倍率(默认 1)
|
|
96
|
+
* [card] 这一行当「卡头」渲染:
|
|
97
|
+
* 左边画一个圆角徽章(字与颜色见「卡片样式」设置),
|
|
98
|
+
* 标题排在徽章右侧,下面画一条分隔线
|
|
96
99
|
*
|
|
97
100
|
* 字号层级(size 标记 = 相对整体字号的倍率):
|
|
98
101
|
* 五角星 —— 由 panel.starSize 单独控制
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
+
* 抬头 0.82 —— 「今日运势指数为」这类说明
|
|
103
|
+
* 卡头 1.0 —— 宜 / 忌 / 地图 的标题,最大
|
|
104
|
+
* 说明 0.72 —— 卡头下面的小字
|
|
102
105
|
*/
|
|
103
106
|
export const DEFAULT_FORMAT = [
|
|
104
|
-
'[center][size=0.
|
|
107
|
+
'[center][size=0.82]您今日的运势指数为:',
|
|
105
108
|
'[center]{stars}',
|
|
106
|
-
'[
|
|
107
|
-
'[size=0.
|
|
108
|
-
'[
|
|
109
|
-
'[size=0.
|
|
110
|
-
'[
|
|
109
|
+
'[card]{good}',
|
|
110
|
+
'[size=0.72]{goodText}',
|
|
111
|
+
'[card]{bad}',
|
|
112
|
+
'[size=0.72]{badText}',
|
|
113
|
+
'[card]地图 {map}',
|
|
114
|
+
'[size=0.72]{mapText}',
|
|
111
115
|
].join('\n')
|
|
112
116
|
|
|
117
|
+
/** 卡片徽章的默认字与颜色(设置页里「卡片样式」可覆盖) */
|
|
118
|
+
export const DEFAULT_CARDS = {
|
|
119
|
+
good: { badge: '宜', color: '#2E7D6B' },
|
|
120
|
+
bad: { badge: '忌', color: '#3E4A5A' },
|
|
121
|
+
map: { badge: '图', color: '#8C5A2B' },
|
|
122
|
+
summary: { badge: '', color: '#C8434F' },
|
|
123
|
+
}
|
|
124
|
+
|
|
113
125
|
/** 地图下面的那句补充说明。留空则模板里的 {mapText} 一行自动省略 */
|
|
114
126
|
export const DEFAULT_MAP_TEXT = ''
|
|
115
127
|
|
package/lib/index.js
CHANGED
|
@@ -62,7 +62,7 @@ function assetPath (name) {
|
|
|
62
62
|
return candidates[0]
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
const DEFAULT_BG = assetPath('bg.png')
|
|
65
|
+
const DEFAULT_BG = assetPath('bg-panel.png')
|
|
66
66
|
|
|
67
67
|
/* ------------------------------------------------------------------ *
|
|
68
68
|
* 配置
|
|
@@ -73,25 +73,29 @@ const panelSchema = Schema.object({
|
|
|
73
73
|
Schema.const('left').description('左对齐(和参考排版一致)'),
|
|
74
74
|
Schema.const('center').description('居中'),
|
|
75
75
|
]).default('left').description('整体对齐方式。想单独控制某一行,可在模板行首写 [left]/[center]'),
|
|
76
|
-
charsPerLine: Schema.number().role('slider').min(6).max(40).step(1).default(
|
|
76
|
+
charsPerLine: Schema.number().role('slider').min(6).max(40).step(1).default(18)
|
|
77
77
|
.description('字号大小:每行大约放几个字。<b>这是最常调的一项</b>,调小字更大'),
|
|
78
78
|
blockFill: Schema.number().role('slider').min(1).max(1.5).step(0.01).default(1.08)
|
|
79
79
|
.description('松紧度:1 = 撑满面板,调大四周留白更多(嫌挤就往大调)'),
|
|
80
80
|
lineHeight: Schema.number().role('slider').min(1).max(2.5).step(0.05).default(1.5)
|
|
81
81
|
.description('行高倍数(调大行与行更松)'),
|
|
82
|
-
starSize: Schema.number().role('slider').min(0.5).max(2.5).step(0.05).default(1.
|
|
82
|
+
starSize: Schema.number().role('slider').min(0.5).max(2.5).step(0.05).default(1.3)
|
|
83
83
|
.description('五角星大小(相对字号倍数)。星数多的时候别调太大,会顶出面板'),
|
|
84
84
|
starSpacing: Schema.number().role('slider').min(1).max(2).step(0.02).default(1.32)
|
|
85
85
|
.description('五角星间距'),
|
|
86
|
+
starStyle: Schema.union([
|
|
87
|
+
Schema.const('gem').description('宝石质感(金色渐变 + 深色描边 + 内高光,默认)'),
|
|
88
|
+
Schema.const('flat').description('单色平涂(旧样式,可逐颗换色)'),
|
|
89
|
+
]).default('gem').description('星星样式'),
|
|
86
90
|
|
|
87
91
|
// ↓ 换底图才需要动,折叠起来
|
|
88
92
|
geometry: Schema.object({
|
|
89
|
-
x: Schema.number().role('ratio').default(0.
|
|
90
|
-
y: Schema.number().role('ratio').default(0.
|
|
91
|
-
w: Schema.number().role('ratio').default(0.
|
|
92
|
-
h: Schema.number().role('ratio').default(0.
|
|
93
|
-
paddingX: Schema.number().role('ratio').default(0.
|
|
94
|
-
paddingY: Schema.number().role('ratio').default(0.
|
|
93
|
+
x: Schema.number().role('ratio').default(0.556).description('面板左边缘:占整图宽度的比例'),
|
|
94
|
+
y: Schema.number().role('ratio').default(0.163).description('面板上边缘:占整图高度的比例'),
|
|
95
|
+
w: Schema.number().role('ratio').default(0.375).description('面板宽度:占整图宽度的比例'),
|
|
96
|
+
h: Schema.number().role('ratio').default(0.697).description('面板高度:占整图高度的比例'),
|
|
97
|
+
paddingX: Schema.number().role('ratio').default(0.045).description('面板内左右留白:占面板宽度的比例'),
|
|
98
|
+
paddingY: Schema.number().role('ratio').default(0.05).description('面板内上下留白:占面板高度的比例'),
|
|
95
99
|
measureRatio: Schema.number().min(0.5).max(1).step(0.01).default(0.98)
|
|
96
100
|
.description('文字块最大宽度占面板宽度的比例'),
|
|
97
101
|
}).collapse().description('面板位置(默认已按内置底图量好,换底图时才需要改)'),
|
|
@@ -106,6 +110,18 @@ const starColorsSchema = Schema.object({
|
|
|
106
110
|
.description('未点亮星星的描边颜色,按顺序循环取用'),
|
|
107
111
|
}).description('五角星配色')
|
|
108
112
|
|
|
113
|
+
const cardThemeSchema = Schema.object({
|
|
114
|
+
badge: Schema.string().default('').description('徽章里的字(留空 = 只画一个空轮廓)'),
|
|
115
|
+
color: Schema.string().role('color').default('#3E4A5A').description('徽章颜色(分隔线也用这个色)'),
|
|
116
|
+
}).description('单张卡片的样式')
|
|
117
|
+
|
|
118
|
+
const cardSchema = Schema.object({
|
|
119
|
+
good: cardThemeSchema.default({ badge: '宜', color: '#2E7D6B' }).description('「宜」那一张'),
|
|
120
|
+
bad: cardThemeSchema.default({ badge: '忌', color: '#3E4A5A' }).description('「忌」那一张'),
|
|
121
|
+
map: cardThemeSchema.default({ badge: '图', color: '#8C5A2B' }).description('「地图」那一张'),
|
|
122
|
+
summary: cardThemeSchema.default({ badge: '', color: '#C8434F' }).description('「小结」那一张(模板里用 [card] 触发)'),
|
|
123
|
+
}).description('卡片徽章与颜色。模板里给某一行加 [card] 就会按这里的样式渲染')
|
|
124
|
+
|
|
109
125
|
const starWeightSchema = Schema.object({
|
|
110
126
|
maxStars: Schema.number().role('slider').min(3).max(10).step(1).default(data.DEFAULT_MAX_STARS)
|
|
111
127
|
.description('一共有几颗星(满星 = 这个数),改这里等于改满分'),
|
|
@@ -207,11 +223,12 @@ exports.Config = Schema.intersect([
|
|
|
207
223
|
panel: panelSchema,
|
|
208
224
|
colors: colorsSchema,
|
|
209
225
|
starColors: starColorsSchema,
|
|
226
|
+
cards: cardSchema,
|
|
210
227
|
|
|
211
228
|
renderAdvanced: Schema.object({
|
|
212
229
|
background: Schema.string().role('textarea').default(DEFAULT_BG)
|
|
213
230
|
.description('底图:支持 http(s) 直链、绝对路径、插件相对路径。留空则用内置底图'),
|
|
214
|
-
outputWidth: Schema.number().role('slider').min(0).max(2560).step(10).default(
|
|
231
|
+
outputWidth: Schema.number().role('slider').min(0).max(2560).step(10).default(1520)
|
|
215
232
|
.description('输出图片宽度(像素)。默认 1280 出图最快;想要更锐利的文字可调到 1920'),
|
|
216
233
|
imageFormat: Schema.union([
|
|
217
234
|
Schema.const('jpeg').description('JPEG(编码最快,推荐)'),
|
|
@@ -310,12 +327,16 @@ function adv (config) {
|
|
|
310
327
|
|
|
311
328
|
function renderCfg (config) {
|
|
312
329
|
return Object.assign({
|
|
313
|
-
background: DEFAULT_BG, outputWidth:
|
|
330
|
+
background: DEFAULT_BG, outputWidth: 1520, imageFormat: 'jpeg', imageQuality: 92,
|
|
314
331
|
renderScale: 2, fallbackText: true, showPanelBackground: false, panelColor: '#F6E7DA',
|
|
315
332
|
panelOpacity: 0, decorations: [], fontFamilies: renderer.defaultFamilies(),
|
|
316
333
|
}, config && config.renderAdvanced)
|
|
317
334
|
}
|
|
318
335
|
|
|
336
|
+
function cardCfg (config) {
|
|
337
|
+
return Object.assign({}, data.DEFAULT_CARDS, config && config.cards)
|
|
338
|
+
}
|
|
339
|
+
|
|
319
340
|
function storageCfg (config) {
|
|
320
341
|
return Object.assign({ databaseCache: true }, config && config.storage)
|
|
321
342
|
}
|
|
@@ -330,8 +351,8 @@ function kwMore (config) {
|
|
|
330
351
|
/** 面板几何(折叠组,缺省用内置底图量好的值) */
|
|
331
352
|
function panelGeom (config) {
|
|
332
353
|
return Object.assign({
|
|
333
|
-
x: 0.
|
|
334
|
-
paddingX: 0.
|
|
354
|
+
x: 0.556, y: 0.163, w: 0.375, h: 0.697,
|
|
355
|
+
paddingX: 0.06, paddingY: 0.05, measureRatio: 0.98,
|
|
335
356
|
}, config && config.panel && config.panel.geometry)
|
|
336
357
|
}
|
|
337
358
|
|
|
@@ -533,6 +554,7 @@ exports.apply = function apply (ctx, config) {
|
|
|
533
554
|
// ⚠️ 必须定义在所有使用者之前:const 有暂时性死区,
|
|
534
555
|
// 放到后面会在启动时直接抛 "Cannot access 'xxx' before initialization"。
|
|
535
556
|
const geom = panelGeom(config)
|
|
557
|
+
const cardTheme = cardCfg(config)
|
|
536
558
|
const render = renderCfg(config)
|
|
537
559
|
const storage = storageCfg(config)
|
|
538
560
|
const keywordMore = kwMore(config)
|
|
@@ -670,8 +692,10 @@ exports.apply = function apply (ctx, config) {
|
|
|
670
692
|
blockFill: Number(config.panel.blockFill),
|
|
671
693
|
starSize: Number(config.panel.starSize),
|
|
672
694
|
starSpacing: Number(config.panel.starSpacing),
|
|
695
|
+
starStyle: config.panel.starStyle,
|
|
673
696
|
starColors: (config.starColors && config.starColors.fill) || null,
|
|
674
697
|
starEmptyColors: (config.starColors && config.starColors.empty) || null,
|
|
698
|
+
cards: cardTheme,
|
|
675
699
|
outputWidth: Number(render.outputWidth) || 0,
|
|
676
700
|
color: render.panelColor,
|
|
677
701
|
opacity: render.showPanelBackground ? Number(render.panelOpacity) : 0,
|
package/lib/render.js
CHANGED
|
@@ -174,16 +174,46 @@ async function loadImageBuffer (ctx, target, relativeBase) {
|
|
|
174
174
|
/** 这些占位符即使和上一行重复也不要删(否则整行都会消失) */
|
|
175
175
|
const SINGLE_TOKEN_BLOCKLIST = ['stars', 'map', 'mapText', 'date', 'userId']
|
|
176
176
|
|
|
177
|
+
/** 徽章尺寸 = 字号 × 这个比例(1.55 约等于参考图里的比例) */
|
|
178
|
+
const CARD_BADGE_RATIO = 1.55
|
|
179
|
+
|
|
180
|
+
/** 卡片主题默认值:徽章上的字 + 颜色。可在设置页覆盖 */
|
|
181
|
+
const DEFAULT_CARDS = {
|
|
182
|
+
good: { badge: '宜', color: '#2E7D6B', titleColor: null },
|
|
183
|
+
bad: { badge: '忌', color: '#3E4A5A', titleColor: null },
|
|
184
|
+
map: { badge: '', color: '#B0306A', titleColor: null },
|
|
185
|
+
summary: { badge: '', color: '#C8434F', titleColor: null },
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 根据这一行用的占位符,挑对应的卡片主题(徽章字/颜色) */
|
|
189
|
+
function cardStyle (tokens, theme) {
|
|
190
|
+
const has = (k) => tokens.indexOf(k) !== -1
|
|
191
|
+
if (has('good') || has('goodText')) return Object.assign({}, DEFAULT_CARDS.good, theme.good)
|
|
192
|
+
if (has('bad') || has('badText')) return Object.assign({}, DEFAULT_CARDS.bad, theme.bad)
|
|
193
|
+
if (has('map')) return Object.assign({}, DEFAULT_CARDS.map, theme.map)
|
|
194
|
+
if (has('mapText')) return Object.assign({}, DEFAULT_CARDS.summary, theme.summary)
|
|
195
|
+
return Object.assign({}, DEFAULT_CARDS.summary, theme.summary)
|
|
196
|
+
}
|
|
197
|
+
|
|
177
198
|
/** 模板行首的对齐标记:'[left]xxx' / '[center]xxx',中文「[左]」「[中]」也行 */
|
|
178
199
|
const ALIGN_MARKER = /^\[(left|center|左|中)\]/i
|
|
179
200
|
/** 行首的字号倍率标记:'[size=0.85]xxx',让某一行比整体字号小一点 */
|
|
180
201
|
const SIZE_MARKER = /^\[size\s*=\s*([\d.]+)\]/i
|
|
202
|
+
/** 行首的卡片标记:'[card]xxx' —— 这一行当卡头渲染(圆角徽章 + 大标题 + 下划分隔线) */
|
|
203
|
+
const CARD_MARKER = /^\[card\]/i
|
|
181
204
|
|
|
182
205
|
function parseLineMarkers (line, fallbackAlign, fallbackSize) {
|
|
183
206
|
let text = line
|
|
184
207
|
let align = fallbackAlign
|
|
185
208
|
let size = fallbackSize
|
|
186
|
-
|
|
209
|
+
let card = false
|
|
210
|
+
for (let i = 0; i < 5; i++) {
|
|
211
|
+
const cardMatched = text.match(CARD_MARKER)
|
|
212
|
+
if (cardMatched) {
|
|
213
|
+
card = true
|
|
214
|
+
text = text.slice(cardMatched[0].length)
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
187
217
|
const alignMatched = text.match(ALIGN_MARKER)
|
|
188
218
|
if (alignMatched) {
|
|
189
219
|
const raw = alignMatched[1].toLowerCase()
|
|
@@ -200,7 +230,45 @@ function parseLineMarkers (line, fallbackAlign, fallbackSize) {
|
|
|
200
230
|
}
|
|
201
231
|
break
|
|
202
232
|
}
|
|
203
|
-
return { align, size, text }
|
|
233
|
+
return { align, size, card, text }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 画一个圆角方形徽章。
|
|
238
|
+
* text 为空时只画轮廓(无字徽章,用于「小结」那种)。
|
|
239
|
+
*/
|
|
240
|
+
function drawBadge (ctx, x, centerY, size, color, text, fontFamilyList) {
|
|
241
|
+
const radius = size * 0.28
|
|
242
|
+
const left = x
|
|
243
|
+
const top = centerY - size / 2
|
|
244
|
+
ctx.save()
|
|
245
|
+
ctx.beginPath()
|
|
246
|
+
ctx.moveTo(left + radius, top)
|
|
247
|
+
ctx.lineTo(left + size - radius, top)
|
|
248
|
+
ctx.quadraticCurveTo(left + size, top, left + size, top + radius)
|
|
249
|
+
ctx.lineTo(left + size, top + size - radius)
|
|
250
|
+
ctx.quadraticCurveTo(left + size, top + size, left + size - radius, top + size)
|
|
251
|
+
ctx.lineTo(left + radius, top + size)
|
|
252
|
+
ctx.quadraticCurveTo(left, top + size, left, top + size - radius)
|
|
253
|
+
ctx.lineTo(left, top + radius)
|
|
254
|
+
ctx.quadraticCurveTo(left, top, left + radius, top)
|
|
255
|
+
ctx.closePath()
|
|
256
|
+
|
|
257
|
+
if (text) {
|
|
258
|
+
ctx.fillStyle = color
|
|
259
|
+
ctx.fill()
|
|
260
|
+
ctx.fillStyle = '#FFFFFF'
|
|
261
|
+
ctx.font = buildFont(size * 0.56, 'bold', fontFamilyList)
|
|
262
|
+
ctx.textAlign = 'center'
|
|
263
|
+
ctx.textBaseline = 'middle'
|
|
264
|
+
ctx.fillText(text, left + size / 2, centerY + size * 0.03)
|
|
265
|
+
ctx.textAlign = 'left'
|
|
266
|
+
} else {
|
|
267
|
+
ctx.strokeStyle = color
|
|
268
|
+
ctx.lineWidth = Math.max(1, size * 0.1)
|
|
269
|
+
ctx.stroke()
|
|
270
|
+
}
|
|
271
|
+
ctx.restore()
|
|
204
272
|
}
|
|
205
273
|
|
|
206
274
|
/**
|
|
@@ -233,6 +301,7 @@ function detectLabelToken (segments) {
|
|
|
233
301
|
function buildLines (template, values, colors, ratio, options) {
|
|
234
302
|
const opts = options || {}
|
|
235
303
|
const fallbackAlign = opts.align === 'center' ? 'center' : 'left'
|
|
304
|
+
const cardTheme = opts.cards || DEFAULT_CARDS
|
|
236
305
|
const lines = []
|
|
237
306
|
let lastToken = null
|
|
238
307
|
let lastValue = null
|
|
@@ -304,6 +373,7 @@ function buildLines (template, values, colors, ratio, options) {
|
|
|
304
373
|
size: marked.size,
|
|
305
374
|
align: marked.align,
|
|
306
375
|
isStars: tokens.length === 1 && tokens[0] === 'stars',
|
|
376
|
+
card: marked.card ? cardStyle(tokens, cardTheme) : null,
|
|
307
377
|
})
|
|
308
378
|
}
|
|
309
379
|
return lines
|
|
@@ -393,6 +463,111 @@ function drawStar (ctx, cx, cy, outer, filled, color, style) {
|
|
|
393
463
|
ctx.restore()
|
|
394
464
|
}
|
|
395
465
|
|
|
466
|
+
/* ------------------------------------------------------------------ *
|
|
467
|
+
* 「宝石质感」星星(参考图那种)
|
|
468
|
+
*
|
|
469
|
+
* 结构从下到上:
|
|
470
|
+
* ① 柔和投影(让星星从纸面上浮起来)
|
|
471
|
+
* ② 外圈描边:深色粗描边,圆角连接
|
|
472
|
+
* ③ 星身填充:竖直线性渐变(顶部亮 → 底部深)
|
|
473
|
+
* ④ 内高光:稍微缩小一圈的淡色描边,做出「边缘反光」的立体感
|
|
474
|
+
* ⑤ 空心星:浅灰平涂 + 深灰粗描边 + 轻微内高光
|
|
475
|
+
* ------------------------------------------------------------------ */
|
|
476
|
+
|
|
477
|
+
const GEM_GOLD = {
|
|
478
|
+
outline: '#C08A2E',
|
|
479
|
+
// 顶部近白 → 中部饱和金 → 底部深金,拉开明暗差才有立体感
|
|
480
|
+
gradient: ['#FFF6C2', '#FFD84F', '#EFA01A', '#D98511'],
|
|
481
|
+
innerLight: '#FFFBE0',
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const GEM_GRAY = {
|
|
485
|
+
outline: '#8A8A88',
|
|
486
|
+
fill: '#DEDDD8',
|
|
487
|
+
innerLight: '#F4F3EF',
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function starPathFor (ctx, cx, cy, outer, opts) {
|
|
491
|
+
const inner = outer * (opts.innerRatio || 0.5)
|
|
492
|
+
const points = starPoints(cx, cy, outer, inner, opts.wobble)
|
|
493
|
+
traceStar(ctx, points, true)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 画一颗宝石质感星星。
|
|
498
|
+
* @param {boolean} filled 点亮 / 未点亮
|
|
499
|
+
* @param {object} conf { filled: {outline,gradient,innerLight}, empty: {outline,fill,innerLight}, shadow }
|
|
500
|
+
*/
|
|
501
|
+
function drawGemStar (ctx, cx, cy, outer, filled, conf) {
|
|
502
|
+
const c = conf || {}
|
|
503
|
+
const style = filled ? Object.assign({}, GEM_GOLD, c.filled) : Object.assign({}, GEM_GRAY, c.empty)
|
|
504
|
+
const outlineWidth = Math.max(1, outer * (filled ? 0.145 : 0.18))
|
|
505
|
+
// 参考图的星星比标准五角星饱满:内圈半径调大让星身更胖;
|
|
506
|
+
// wobble 只留一点点不规则感,给大了星角会变成波浪状
|
|
507
|
+
const wobble = c.wobble === undefined ? 0.03 : c.wobble
|
|
508
|
+
// 参数扫描结论:0.44 角最清楚又不尖,再大就变圆球、再小就变瘦星
|
|
509
|
+
const innerRatio = c.innerRatio || 0.44
|
|
510
|
+
const bodyOuter = outer - outlineWidth / 2
|
|
511
|
+
|
|
512
|
+
ctx.save()
|
|
513
|
+
ctx.lineJoin = 'round'
|
|
514
|
+
ctx.lineCap = 'round'
|
|
515
|
+
|
|
516
|
+
// ① 投影
|
|
517
|
+
if (c.shadow !== false) {
|
|
518
|
+
ctx.save()
|
|
519
|
+
ctx.shadowColor = filled ? 'rgba(150, 90, 20, 0.35)' : 'rgba(90, 90, 90, 0.25)'
|
|
520
|
+
ctx.shadowBlur = outer * 0.2
|
|
521
|
+
ctx.shadowOffsetY = outer * 0.07
|
|
522
|
+
starPathFor(ctx, cx, cy, bodyOuter, { innerRatio, wobble })
|
|
523
|
+
ctx.fillStyle = filled ? '#FFD84F' : (style.fill || GEM_GRAY.fill)
|
|
524
|
+
ctx.fill()
|
|
525
|
+
ctx.restore()
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ② 星身:渐变从左上到右下,模拟斜上方光源
|
|
529
|
+
starPathFor(ctx, cx, cy, bodyOuter, { innerRatio, wobble })
|
|
530
|
+
if (filled) {
|
|
531
|
+
const grad = ctx.createLinearGradient(cx - outer * 0.5, cy - outer, cx + outer * 0.35, cy + outer)
|
|
532
|
+
const stops = style.gradient || GEM_GOLD.gradient
|
|
533
|
+
stops.forEach((color, i) => grad.addColorStop(i / Math.max(1, stops.length - 1), color))
|
|
534
|
+
ctx.fillStyle = grad
|
|
535
|
+
} else {
|
|
536
|
+
ctx.fillStyle = style.fill || GEM_GRAY.fill
|
|
537
|
+
}
|
|
538
|
+
ctx.fill()
|
|
539
|
+
|
|
540
|
+
// ③ 描边
|
|
541
|
+
ctx.lineWidth = outlineWidth
|
|
542
|
+
ctx.strokeStyle = style.outline
|
|
543
|
+
ctx.stroke()
|
|
544
|
+
|
|
545
|
+
// ④ 内高光:贴在内侧的一圈细亮线,做出「边缘反光」
|
|
546
|
+
const inset = outlineWidth * 1.15
|
|
547
|
+
starPathFor(ctx, cx, cy, Math.max(2, bodyOuter - inset), { innerRatio, wobble })
|
|
548
|
+
ctx.lineWidth = Math.max(1, outlineWidth * 0.45)
|
|
549
|
+
ctx.strokeStyle = style.innerLight || (filled ? GEM_GOLD.innerLight : GEM_GRAY.innerLight)
|
|
550
|
+
ctx.globalAlpha = filled ? 0.55 : 0.45
|
|
551
|
+
ctx.stroke()
|
|
552
|
+
ctx.globalAlpha = 1
|
|
553
|
+
|
|
554
|
+
// ⑤ 中心亮斑:参考图的金星中间有一小块高光,这块最能出「宝石感」
|
|
555
|
+
if (filled) {
|
|
556
|
+
const hl = ctx.createRadialGradient(
|
|
557
|
+
cx - outer * 0.08, cy - outer * 0.16, 0,
|
|
558
|
+
cx - outer * 0.08, cy - outer * 0.16, outer * 0.55,
|
|
559
|
+
)
|
|
560
|
+
hl.addColorStop(0, 'rgba(255, 253, 224, 0.85)')
|
|
561
|
+
hl.addColorStop(0.45, 'rgba(255, 246, 190, 0.35)')
|
|
562
|
+
hl.addColorStop(1, 'rgba(255, 246, 190, 0)')
|
|
563
|
+
starPathFor(ctx, cx, cy, Math.max(2, bodyOuter - inset), { innerRatio, wobble })
|
|
564
|
+
ctx.fillStyle = hl
|
|
565
|
+
ctx.fill()
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
ctx.restore()
|
|
569
|
+
}
|
|
570
|
+
|
|
396
571
|
/**
|
|
397
572
|
* 生成一颗「整行星」的绘制块。
|
|
398
573
|
* 返回一个 run 项:{ sprite: true, w, h, star: {...} }
|
|
@@ -425,12 +600,19 @@ function createStarSprite (count, size, total, fillColors, emptyColors, spacing,
|
|
|
425
600
|
/** 绘制起点是「第 1 颗星左沿」,不是 center-of-first-star */
|
|
426
601
|
function paintStarSprite (ctx, sprite, left, centerY) {
|
|
427
602
|
const star = sprite.star
|
|
603
|
+
const style = star.style || {}
|
|
604
|
+
// style.mode === 'gem' 用参考图那种宝石质感;否则用原来的单色星
|
|
605
|
+
const gem = style.mode === 'gem'
|
|
428
606
|
for (let i = 0; i < star.total; i++) {
|
|
429
607
|
const cx = left + star.size / 2 + star.step * i
|
|
430
608
|
const filled = i < star.count
|
|
609
|
+
if (gem) {
|
|
610
|
+
drawGemStar(ctx, cx, centerY, star.size / 2, filled, style)
|
|
611
|
+
continue
|
|
612
|
+
}
|
|
431
613
|
const palette = filled ? star.fill : star.empty
|
|
432
614
|
const color = palette[i % palette.length]
|
|
433
|
-
drawStar(ctx, cx, centerY, star.size / 2, filled, color,
|
|
615
|
+
drawStar(ctx, cx, centerY, star.size / 2, filled, color, style)
|
|
434
616
|
}
|
|
435
617
|
}
|
|
436
618
|
|
|
@@ -591,7 +773,9 @@ async function renderFortune (ctx, options) {
|
|
|
591
773
|
|
|
592
774
|
// 整图对齐方式:left(默认,和参考排版一致)/ center
|
|
593
775
|
const align = panel.align === 'center' ? 'center' : 'left'
|
|
776
|
+
const cardTheme = Object.assign({}, DEFAULT_CARDS, panel.cards)
|
|
594
777
|
const starOptions = {
|
|
778
|
+
mode: panel.starStyle === 'gem' ? 'gem' : 'flat',
|
|
595
779
|
size: Number(panel.starSize) || 1.15,
|
|
596
780
|
spacing: Number(panel.starSpacing) || 1.32,
|
|
597
781
|
fill: (panel.starColors && panel.starColors.length) ? panel.starColors : STAR_FILL_COLORS,
|
|
@@ -613,16 +797,31 @@ async function renderFortune (ctx, options) {
|
|
|
613
797
|
const buildAndMeasure = (fontSize) => {
|
|
614
798
|
const laid = []
|
|
615
799
|
let finalWidth = measureWidth
|
|
616
|
-
|
|
800
|
+
let lastCard = null
|
|
801
|
+
for (const line of buildLines(template, values, colors, 1, { align, cards: cardTheme })) {
|
|
617
802
|
const spec = fontSize * (line.ratio || 1) * (line.size || 1)
|
|
618
803
|
|
|
804
|
+
// 卡头:左边留出徽章的位置,标题在其右侧起排
|
|
805
|
+
let indent = 0
|
|
806
|
+
let badge = null
|
|
807
|
+
if (line.card) {
|
|
808
|
+
const badgeSize = spec * CARD_BADGE_RATIO
|
|
809
|
+
indent = badgeSize + spec * 0.42
|
|
810
|
+
badge = {
|
|
811
|
+
size: badgeSize,
|
|
812
|
+
color: line.card.color,
|
|
813
|
+
text: line.card.badge || '',
|
|
814
|
+
dividerColor: line.card.divider || line.card.color,
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
619
818
|
// 整行星:不排版文字,交给绘制阶段画彩色五角星
|
|
620
819
|
if (line.isStars) {
|
|
621
820
|
// 星星行必须自己兜住宽度:整行星不参与换行,字号再大也只会把星行撑出面板,
|
|
622
821
|
// 所以按可用宽度反推星直径上限,星数越多星越小(8 颗星时尤其明显)
|
|
623
822
|
const starPx = starPixelSize(spec, starTotal, starOptions.size, starOptions.spacing, measureWidth)
|
|
624
823
|
const sprite = createStarSprite(starCount, starPx, starTotal,
|
|
625
|
-
starOptions.fill, starOptions.empty, starOptions.spacing)
|
|
824
|
+
starOptions.fill, starOptions.empty, starOptions.spacing, starOptions)
|
|
626
825
|
laid.push({
|
|
627
826
|
runs: [sprite],
|
|
628
827
|
gap: line.gap,
|
|
@@ -630,22 +829,27 @@ async function renderFortune (ctx, options) {
|
|
|
630
829
|
height: sprite.h * 1.5,
|
|
631
830
|
align: 'center',
|
|
632
831
|
isStars: true,
|
|
832
|
+
badge: null,
|
|
833
|
+
indent: 0,
|
|
834
|
+
divider: false,
|
|
633
835
|
})
|
|
836
|
+
lastCard = null
|
|
634
837
|
continue
|
|
635
838
|
}
|
|
636
839
|
|
|
637
|
-
|
|
840
|
+
const lineWidth = Math.max(40, finalWidth - indent)
|
|
841
|
+
let chunks = layoutParagraph(line, lineWidth, g, spec)
|
|
638
842
|
|
|
639
843
|
// 孤字控制:如果最后一行只剩一两个字,被硬挤下来会很难看,
|
|
640
844
|
// 就把排版宽度一点点收窄,逼出更均衡的断行(最多试 25 次)
|
|
641
845
|
if (chunks.length > 1) {
|
|
642
|
-
const natural = layoutParagraph(line,
|
|
846
|
+
const natural = layoutParagraph(line, lineWidth, g, spec)
|
|
643
847
|
const tail = natural[natural.length - 1].map(run => run.char).join('')
|
|
644
848
|
if (tail.length <= 2 && natural.length <= 4) {
|
|
645
|
-
finalWidth =
|
|
849
|
+
finalWidth = lineWidth
|
|
646
850
|
for (let i = 0; i < 25; i++) {
|
|
647
|
-
finalWidth -= Math.max(1,
|
|
648
|
-
if (finalWidth <
|
|
851
|
+
finalWidth -= Math.max(1, lineWidth * 0.02)
|
|
852
|
+
if (finalWidth < lineWidth * 0.55) break
|
|
649
853
|
const attempt = layoutParagraph(line, finalWidth, g, spec)
|
|
650
854
|
const attemptTail = attempt[attempt.length - 1].map(run => run.char).join('')
|
|
651
855
|
if (attemptTail.length > 2 || attempt.length > natural.length + 1) {
|
|
@@ -657,6 +861,9 @@ async function renderFortune (ctx, options) {
|
|
|
657
861
|
}
|
|
658
862
|
}
|
|
659
863
|
|
|
864
|
+
// 卡头下方的分隔线:只在「接下来还有内容」时画,最后一段不画
|
|
865
|
+
const divider = !!(line.card && lastCard !== null)
|
|
866
|
+
|
|
660
867
|
for (let i = 0; i < chunks.length; i++) {
|
|
661
868
|
laid.push({
|
|
662
869
|
runs: chunks[i],
|
|
@@ -665,23 +872,46 @@ async function renderFortune (ctx, options) {
|
|
|
665
872
|
height: spec * lineHeightRatio,
|
|
666
873
|
align: line.align,
|
|
667
874
|
isStars: false,
|
|
875
|
+
badge: i === 0 ? badge : null,
|
|
876
|
+
indent,
|
|
877
|
+
divider: divider && i === 0,
|
|
668
878
|
})
|
|
669
879
|
}
|
|
880
|
+
lastCard = line.card ? true : null
|
|
670
881
|
}
|
|
671
882
|
const total = laid.reduce((sum, line) => sum + line.gap * fontSize + line.height, 0)
|
|
672
883
|
return { lines: laid, total }
|
|
673
884
|
}
|
|
674
885
|
|
|
675
886
|
// 先按 baseFont 量一次,再二分放大。
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
887
|
+
// 两条约束同时生效:
|
|
888
|
+
// ① 高度:撑满可用高度(内容少时字就大,填满面板)
|
|
889
|
+
// ② 宽度:不许把「内容最长的那一行」撑到换行 —— 没有这条的话,
|
|
890
|
+
// 自动放大只看高度,长地图名会被硬挤成两行
|
|
679
891
|
const MAX_FONT_SCALE = 2.4
|
|
680
|
-
// 只允许撑到可用高度的 97%:留一点余量,避免笔画降部顶到纸框上
|
|
681
892
|
const FIT_LIMIT = 0.97
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
893
|
+
const fitsWidth = (fs) => {
|
|
894
|
+
// 注意:不能量 buildAndMeasure 的结果 —— 那里已经按宽度换过行了,
|
|
895
|
+
// 每段必然「不超宽」,等于没检查。要量的是「这一行原始内容」的自然宽度。
|
|
896
|
+
for (const line of buildLines(template, values, colors, 1, { align, cards: cardTheme })) {
|
|
897
|
+
if (line.isStars) continue
|
|
898
|
+
const spec = fs * (line.ratio || 1) * (line.size || 1)
|
|
899
|
+
let natural = 0
|
|
900
|
+
for (const segment of line.segments) {
|
|
901
|
+
g.font = buildFont(spec * (segment.size || 1), segment.bold ? 'bold' : '', currentFamilies)
|
|
902
|
+
natural += g.measureText(segment.text).width
|
|
903
|
+
}
|
|
904
|
+
const indent = line.card ? spec * CARD_BADGE_RATIO + spec * 0.42 : 0
|
|
905
|
+
if (natural > Math.max(40, measureWidth - indent) + 0.5) return false
|
|
906
|
+
}
|
|
907
|
+
return true
|
|
908
|
+
}
|
|
909
|
+
const findFontSize = () => {
|
|
910
|
+
const unit = buildAndMeasure(baseFont)
|
|
911
|
+
if (unit.total <= 0) return baseFont
|
|
912
|
+
if (unit.total >= textHeight) {
|
|
913
|
+
return baseFont * Math.max(0.5, (textHeight * FIT_LIMIT) / unit.total)
|
|
914
|
+
}
|
|
685
915
|
let low = 1
|
|
686
916
|
let high = MAX_FONT_SCALE
|
|
687
917
|
for (let i = 0; i < 14; i++) {
|
|
@@ -689,10 +919,15 @@ async function renderFortune (ctx, options) {
|
|
|
689
919
|
if (buildAndMeasure(baseFont * mid).total <= textHeight * FIT_LIMIT) low = mid
|
|
690
920
|
else high = mid
|
|
691
921
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
922
|
+
let fs = baseFont * low
|
|
923
|
+
// 宽度回退:每次退 2%,最多退到 baseFont 的 45%
|
|
924
|
+
for (let i = 0; i < 30 && !fitsWidth(fs); i++) {
|
|
925
|
+
fs *= 0.98
|
|
926
|
+
if (fs < baseFont * 0.45) break
|
|
927
|
+
}
|
|
928
|
+
return fs
|
|
695
929
|
}
|
|
930
|
+
const fontSize = findFontSize()
|
|
696
931
|
|
|
697
932
|
const fitted = buildAndMeasure(fontSize)
|
|
698
933
|
const lines = fitted.lines
|
|
@@ -708,12 +943,13 @@ async function renderFortune (ctx, options) {
|
|
|
708
943
|
const rowCenter = cursorY + line.height / 2
|
|
709
944
|
const lineWidth = line.runs.reduce((sum, run) => sum + run.w, 0)
|
|
710
945
|
|
|
711
|
-
// 对齐:center 居中(扣掉行尾整角标点占的空白),left
|
|
946
|
+
// 对齐:center 居中(扣掉行尾整角标点占的空白),left 从面板左内侧起排(卡头再缩进徽章宽度)
|
|
712
947
|
const lineAlign = line.align === 'center' ? 'center' : align
|
|
713
948
|
const trim = lineAlign === 'center' ? trailingPunctWidth(line.runs) : 0
|
|
949
|
+
const indent = line.indent || 0
|
|
714
950
|
let x = lineAlign === 'center'
|
|
715
951
|
? panelRect.x + panelRect.w / 2 - (lineWidth - trim) / 2
|
|
716
|
-
: panelRect.x + paddingX
|
|
952
|
+
: panelRect.x + paddingX + indent
|
|
717
953
|
|
|
718
954
|
if (line.isStars) {
|
|
719
955
|
// 整行星:五角星在面板里居中(paintStarSprite 的 x 参数是第 1 颗星的左沿)
|
|
@@ -724,6 +960,12 @@ async function renderFortune (ctx, options) {
|
|
|
724
960
|
continue
|
|
725
961
|
}
|
|
726
962
|
|
|
963
|
+
// 卡头左边的圆角徽章
|
|
964
|
+
if (line.badge) {
|
|
965
|
+
drawBadge(g, panelRect.x + paddingX, rowCenter,
|
|
966
|
+
line.badge.size, line.badge.color, line.badge.text, currentFamilies)
|
|
967
|
+
}
|
|
968
|
+
|
|
727
969
|
for (const run of line.runs) {
|
|
728
970
|
if (run.sprite) {
|
|
729
971
|
paintStarSprite(g, run, x, rowCenter)
|
|
@@ -735,6 +977,23 @@ async function renderFortune (ctx, options) {
|
|
|
735
977
|
g.fillText(run.char, x, rowCenter)
|
|
736
978
|
x += run.w
|
|
737
979
|
}
|
|
980
|
+
|
|
981
|
+
// 卡头下方的分隔线(只在后面还有内容时画)
|
|
982
|
+
if (line.divider) {
|
|
983
|
+
const left = panelRect.x + paddingX + indent
|
|
984
|
+
const right = panelRect.x + panelRect.w - paddingX
|
|
985
|
+
const y = cursorY + line.height * 0.98
|
|
986
|
+
g.save()
|
|
987
|
+
// 0.28 在宣纸上几乎看不见,加到 0.5 才有参考图那种分隔感
|
|
988
|
+
g.strokeStyle = withAlpha(line.badge ? line.badge.dividerColor : colors.text, 0.5)
|
|
989
|
+
g.lineWidth = Math.max(1, line.font * 0.06)
|
|
990
|
+
g.beginPath()
|
|
991
|
+
g.moveTo(left, y)
|
|
992
|
+
g.lineTo(right, y)
|
|
993
|
+
g.stroke()
|
|
994
|
+
g.restore()
|
|
995
|
+
}
|
|
996
|
+
|
|
738
997
|
cursorY += line.height
|
|
739
998
|
}
|
|
740
999
|
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -89,7 +89,14 @@ npm i @napi-rs/canvas # 出图用,预编译二进制,不需要编译工
|
|
|
89
89
|
3. `panel.paddingY` / `panel.paddingX` 调大 —— 文字区整体收窄
|
|
90
90
|
4. 还挤就是文案太长,配合改 `template` 或缩短宜忌文案
|
|
91
91
|
|
|
92
|
-
##
|
|
92
|
+
## 五角星(两种样式)
|
|
93
|
+
|
|
94
|
+
默认是 **宝石质感**:金色渐变 + 深金描边 + 内圈高光 + 中心亮斑,未点亮的星是浅灰平涂 + 深灰描边。
|
|
95
|
+
这是参考图那种立体星星,和「深色系平涂」是两个可切换的样式(设置页 panel.starStyle)。
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 深色系五角星(flat 样式)
|
|
93
100
|
|
|
94
101
|
运势指数不是文字,而是**画出来的矢量五角星**。默认 **8 颗星,满星 = 8/8**;形状是圆润的
|
|
95
102
|
「Q 版胖星星」(二次贝塞尔抹圆尖角,内圈半径带一点随机起伏),配色是深色系:
|
package/assets/bg.png
DELETED
|
Binary file
|