koishi-plugin-ll-bns-fortune 0.2.3 → 0.3.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 +31 -12
- package/lib/render.js +166 -20
- package/package.json +1 -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,7 +73,7 @@ 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 = 撑满面板,调大四周留白更多(嫌挤就往大调)'),
|
|
@@ -86,12 +86,12 @@ const panelSchema = Schema.object({
|
|
|
86
86
|
|
|
87
87
|
// ↓ 换底图才需要动,折叠起来
|
|
88
88
|
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.
|
|
89
|
+
x: Schema.number().role('ratio').default(0.556).description('面板左边缘:占整图宽度的比例'),
|
|
90
|
+
y: Schema.number().role('ratio').default(0.163).description('面板上边缘:占整图高度的比例'),
|
|
91
|
+
w: Schema.number().role('ratio').default(0.375).description('面板宽度:占整图宽度的比例'),
|
|
92
|
+
h: Schema.number().role('ratio').default(0.697).description('面板高度:占整图高度的比例'),
|
|
93
|
+
paddingX: Schema.number().role('ratio').default(0.045).description('面板内左右留白:占面板宽度的比例'),
|
|
94
|
+
paddingY: Schema.number().role('ratio').default(0.05).description('面板内上下留白:占面板高度的比例'),
|
|
95
95
|
measureRatio: Schema.number().min(0.5).max(1).step(0.01).default(0.98)
|
|
96
96
|
.description('文字块最大宽度占面板宽度的比例'),
|
|
97
97
|
}).collapse().description('面板位置(默认已按内置底图量好,换底图时才需要改)'),
|
|
@@ -106,6 +106,18 @@ const starColorsSchema = Schema.object({
|
|
|
106
106
|
.description('未点亮星星的描边颜色,按顺序循环取用'),
|
|
107
107
|
}).description('五角星配色')
|
|
108
108
|
|
|
109
|
+
const cardThemeSchema = Schema.object({
|
|
110
|
+
badge: Schema.string().default('').description('徽章里的字(留空 = 只画一个空轮廓)'),
|
|
111
|
+
color: Schema.string().role('color').default('#3E4A5A').description('徽章颜色(分隔线也用这个色)'),
|
|
112
|
+
}).description('单张卡片的样式')
|
|
113
|
+
|
|
114
|
+
const cardSchema = Schema.object({
|
|
115
|
+
good: cardThemeSchema.default({ badge: '宜', color: '#2E7D6B' }).description('「宜」那一张'),
|
|
116
|
+
bad: cardThemeSchema.default({ badge: '忌', color: '#3E4A5A' }).description('「忌」那一张'),
|
|
117
|
+
map: cardThemeSchema.default({ badge: '图', color: '#8C5A2B' }).description('「地图」那一张'),
|
|
118
|
+
summary: cardThemeSchema.default({ badge: '', color: '#C8434F' }).description('「小结」那一张(模板里用 [card] 触发)'),
|
|
119
|
+
}).description('卡片徽章与颜色。模板里给某一行加 [card] 就会按这里的样式渲染')
|
|
120
|
+
|
|
109
121
|
const starWeightSchema = Schema.object({
|
|
110
122
|
maxStars: Schema.number().role('slider').min(3).max(10).step(1).default(data.DEFAULT_MAX_STARS)
|
|
111
123
|
.description('一共有几颗星(满星 = 这个数),改这里等于改满分'),
|
|
@@ -207,11 +219,12 @@ exports.Config = Schema.intersect([
|
|
|
207
219
|
panel: panelSchema,
|
|
208
220
|
colors: colorsSchema,
|
|
209
221
|
starColors: starColorsSchema,
|
|
222
|
+
cards: cardSchema,
|
|
210
223
|
|
|
211
224
|
renderAdvanced: Schema.object({
|
|
212
225
|
background: Schema.string().role('textarea').default(DEFAULT_BG)
|
|
213
226
|
.description('底图:支持 http(s) 直链、绝对路径、插件相对路径。留空则用内置底图'),
|
|
214
|
-
outputWidth: Schema.number().role('slider').min(0).max(2560).step(10).default(
|
|
227
|
+
outputWidth: Schema.number().role('slider').min(0).max(2560).step(10).default(1520)
|
|
215
228
|
.description('输出图片宽度(像素)。默认 1280 出图最快;想要更锐利的文字可调到 1920'),
|
|
216
229
|
imageFormat: Schema.union([
|
|
217
230
|
Schema.const('jpeg').description('JPEG(编码最快,推荐)'),
|
|
@@ -310,12 +323,16 @@ function adv (config) {
|
|
|
310
323
|
|
|
311
324
|
function renderCfg (config) {
|
|
312
325
|
return Object.assign({
|
|
313
|
-
background: DEFAULT_BG, outputWidth:
|
|
326
|
+
background: DEFAULT_BG, outputWidth: 1520, imageFormat: 'jpeg', imageQuality: 92,
|
|
314
327
|
renderScale: 2, fallbackText: true, showPanelBackground: false, panelColor: '#F6E7DA',
|
|
315
328
|
panelOpacity: 0, decorations: [], fontFamilies: renderer.defaultFamilies(),
|
|
316
329
|
}, config && config.renderAdvanced)
|
|
317
330
|
}
|
|
318
331
|
|
|
332
|
+
function cardCfg (config) {
|
|
333
|
+
return Object.assign({}, data.DEFAULT_CARDS, config && config.cards)
|
|
334
|
+
}
|
|
335
|
+
|
|
319
336
|
function storageCfg (config) {
|
|
320
337
|
return Object.assign({ databaseCache: true }, config && config.storage)
|
|
321
338
|
}
|
|
@@ -330,8 +347,8 @@ function kwMore (config) {
|
|
|
330
347
|
/** 面板几何(折叠组,缺省用内置底图量好的值) */
|
|
331
348
|
function panelGeom (config) {
|
|
332
349
|
return Object.assign({
|
|
333
|
-
x: 0.
|
|
334
|
-
paddingX: 0.
|
|
350
|
+
x: 0.556, y: 0.163, w: 0.375, h: 0.697,
|
|
351
|
+
paddingX: 0.06, paddingY: 0.05, measureRatio: 0.98,
|
|
335
352
|
}, config && config.panel && config.panel.geometry)
|
|
336
353
|
}
|
|
337
354
|
|
|
@@ -533,6 +550,7 @@ exports.apply = function apply (ctx, config) {
|
|
|
533
550
|
// ⚠️ 必须定义在所有使用者之前:const 有暂时性死区,
|
|
534
551
|
// 放到后面会在启动时直接抛 "Cannot access 'xxx' before initialization"。
|
|
535
552
|
const geom = panelGeom(config)
|
|
553
|
+
const cardTheme = cardCfg(config)
|
|
536
554
|
const render = renderCfg(config)
|
|
537
555
|
const storage = storageCfg(config)
|
|
538
556
|
const keywordMore = kwMore(config)
|
|
@@ -672,6 +690,7 @@ exports.apply = function apply (ctx, config) {
|
|
|
672
690
|
starSpacing: Number(config.panel.starSpacing),
|
|
673
691
|
starColors: (config.starColors && config.starColors.fill) || null,
|
|
674
692
|
starEmptyColors: (config.starColors && config.starColors.empty) || null,
|
|
693
|
+
cards: cardTheme,
|
|
675
694
|
outputWidth: Number(render.outputWidth) || 0,
|
|
676
695
|
color: render.panelColor,
|
|
677
696
|
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
|
|
@@ -591,6 +661,7 @@ async function renderFortune (ctx, options) {
|
|
|
591
661
|
|
|
592
662
|
// 整图对齐方式:left(默认,和参考排版一致)/ center
|
|
593
663
|
const align = panel.align === 'center' ? 'center' : 'left'
|
|
664
|
+
const cardTheme = Object.assign({}, DEFAULT_CARDS, panel.cards)
|
|
594
665
|
const starOptions = {
|
|
595
666
|
size: Number(panel.starSize) || 1.15,
|
|
596
667
|
spacing: Number(panel.starSpacing) || 1.32,
|
|
@@ -613,9 +684,24 @@ async function renderFortune (ctx, options) {
|
|
|
613
684
|
const buildAndMeasure = (fontSize) => {
|
|
614
685
|
const laid = []
|
|
615
686
|
let finalWidth = measureWidth
|
|
616
|
-
|
|
687
|
+
let lastCard = null
|
|
688
|
+
for (const line of buildLines(template, values, colors, 1, { align, cards: cardTheme })) {
|
|
617
689
|
const spec = fontSize * (line.ratio || 1) * (line.size || 1)
|
|
618
690
|
|
|
691
|
+
// 卡头:左边留出徽章的位置,标题在其右侧起排
|
|
692
|
+
let indent = 0
|
|
693
|
+
let badge = null
|
|
694
|
+
if (line.card) {
|
|
695
|
+
const badgeSize = spec * CARD_BADGE_RATIO
|
|
696
|
+
indent = badgeSize + spec * 0.42
|
|
697
|
+
badge = {
|
|
698
|
+
size: badgeSize,
|
|
699
|
+
color: line.card.color,
|
|
700
|
+
text: line.card.badge || '',
|
|
701
|
+
dividerColor: line.card.divider || line.card.color,
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
619
705
|
// 整行星:不排版文字,交给绘制阶段画彩色五角星
|
|
620
706
|
if (line.isStars) {
|
|
621
707
|
// 星星行必须自己兜住宽度:整行星不参与换行,字号再大也只会把星行撑出面板,
|
|
@@ -630,22 +716,27 @@ async function renderFortune (ctx, options) {
|
|
|
630
716
|
height: sprite.h * 1.5,
|
|
631
717
|
align: 'center',
|
|
632
718
|
isStars: true,
|
|
719
|
+
badge: null,
|
|
720
|
+
indent: 0,
|
|
721
|
+
divider: false,
|
|
633
722
|
})
|
|
723
|
+
lastCard = null
|
|
634
724
|
continue
|
|
635
725
|
}
|
|
636
726
|
|
|
637
|
-
|
|
727
|
+
const lineWidth = Math.max(40, finalWidth - indent)
|
|
728
|
+
let chunks = layoutParagraph(line, lineWidth, g, spec)
|
|
638
729
|
|
|
639
730
|
// 孤字控制:如果最后一行只剩一两个字,被硬挤下来会很难看,
|
|
640
731
|
// 就把排版宽度一点点收窄,逼出更均衡的断行(最多试 25 次)
|
|
641
732
|
if (chunks.length > 1) {
|
|
642
|
-
const natural = layoutParagraph(line,
|
|
733
|
+
const natural = layoutParagraph(line, lineWidth, g, spec)
|
|
643
734
|
const tail = natural[natural.length - 1].map(run => run.char).join('')
|
|
644
735
|
if (tail.length <= 2 && natural.length <= 4) {
|
|
645
|
-
finalWidth =
|
|
736
|
+
finalWidth = lineWidth
|
|
646
737
|
for (let i = 0; i < 25; i++) {
|
|
647
|
-
finalWidth -= Math.max(1,
|
|
648
|
-
if (finalWidth <
|
|
738
|
+
finalWidth -= Math.max(1, lineWidth * 0.02)
|
|
739
|
+
if (finalWidth < lineWidth * 0.55) break
|
|
649
740
|
const attempt = layoutParagraph(line, finalWidth, g, spec)
|
|
650
741
|
const attemptTail = attempt[attempt.length - 1].map(run => run.char).join('')
|
|
651
742
|
if (attemptTail.length > 2 || attempt.length > natural.length + 1) {
|
|
@@ -657,6 +748,9 @@ async function renderFortune (ctx, options) {
|
|
|
657
748
|
}
|
|
658
749
|
}
|
|
659
750
|
|
|
751
|
+
// 卡头下方的分隔线:只在「接下来还有内容」时画,最后一段不画
|
|
752
|
+
const divider = !!(line.card && lastCard !== null)
|
|
753
|
+
|
|
660
754
|
for (let i = 0; i < chunks.length; i++) {
|
|
661
755
|
laid.push({
|
|
662
756
|
runs: chunks[i],
|
|
@@ -665,23 +759,46 @@ async function renderFortune (ctx, options) {
|
|
|
665
759
|
height: spec * lineHeightRatio,
|
|
666
760
|
align: line.align,
|
|
667
761
|
isStars: false,
|
|
762
|
+
badge: i === 0 ? badge : null,
|
|
763
|
+
indent,
|
|
764
|
+
divider: divider && i === 0,
|
|
668
765
|
})
|
|
669
766
|
}
|
|
767
|
+
lastCard = line.card ? true : null
|
|
670
768
|
}
|
|
671
769
|
const total = laid.reduce((sum, line) => sum + line.gap * fontSize + line.height, 0)
|
|
672
770
|
return { lines: laid, total }
|
|
673
771
|
}
|
|
674
772
|
|
|
675
773
|
// 先按 baseFont 量一次,再二分放大。
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
774
|
+
// 两条约束同时生效:
|
|
775
|
+
// ① 高度:撑满可用高度(内容少时字就大,填满面板)
|
|
776
|
+
// ② 宽度:不许把「内容最长的那一行」撑到换行 —— 没有这条的话,
|
|
777
|
+
// 自动放大只看高度,长地图名会被硬挤成两行
|
|
679
778
|
const MAX_FONT_SCALE = 2.4
|
|
680
|
-
// 只允许撑到可用高度的 97%:留一点余量,避免笔画降部顶到纸框上
|
|
681
779
|
const FIT_LIMIT = 0.97
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
780
|
+
const fitsWidth = (fs) => {
|
|
781
|
+
// 注意:不能量 buildAndMeasure 的结果 —— 那里已经按宽度换过行了,
|
|
782
|
+
// 每段必然「不超宽」,等于没检查。要量的是「这一行原始内容」的自然宽度。
|
|
783
|
+
for (const line of buildLines(template, values, colors, 1, { align, cards: cardTheme })) {
|
|
784
|
+
if (line.isStars) continue
|
|
785
|
+
const spec = fs * (line.ratio || 1) * (line.size || 1)
|
|
786
|
+
let natural = 0
|
|
787
|
+
for (const segment of line.segments) {
|
|
788
|
+
g.font = buildFont(spec * (segment.size || 1), segment.bold ? 'bold' : '', currentFamilies)
|
|
789
|
+
natural += g.measureText(segment.text).width
|
|
790
|
+
}
|
|
791
|
+
const indent = line.card ? spec * CARD_BADGE_RATIO + spec * 0.42 : 0
|
|
792
|
+
if (natural > Math.max(40, measureWidth - indent) + 0.5) return false
|
|
793
|
+
}
|
|
794
|
+
return true
|
|
795
|
+
}
|
|
796
|
+
const findFontSize = () => {
|
|
797
|
+
const unit = buildAndMeasure(baseFont)
|
|
798
|
+
if (unit.total <= 0) return baseFont
|
|
799
|
+
if (unit.total >= textHeight) {
|
|
800
|
+
return baseFont * Math.max(0.5, (textHeight * FIT_LIMIT) / unit.total)
|
|
801
|
+
}
|
|
685
802
|
let low = 1
|
|
686
803
|
let high = MAX_FONT_SCALE
|
|
687
804
|
for (let i = 0; i < 14; i++) {
|
|
@@ -689,10 +806,15 @@ async function renderFortune (ctx, options) {
|
|
|
689
806
|
if (buildAndMeasure(baseFont * mid).total <= textHeight * FIT_LIMIT) low = mid
|
|
690
807
|
else high = mid
|
|
691
808
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
809
|
+
let fs = baseFont * low
|
|
810
|
+
// 宽度回退:每次退 2%,最多退到 baseFont 的 45%
|
|
811
|
+
for (let i = 0; i < 30 && !fitsWidth(fs); i++) {
|
|
812
|
+
fs *= 0.98
|
|
813
|
+
if (fs < baseFont * 0.45) break
|
|
814
|
+
}
|
|
815
|
+
return fs
|
|
695
816
|
}
|
|
817
|
+
const fontSize = findFontSize()
|
|
696
818
|
|
|
697
819
|
const fitted = buildAndMeasure(fontSize)
|
|
698
820
|
const lines = fitted.lines
|
|
@@ -708,12 +830,13 @@ async function renderFortune (ctx, options) {
|
|
|
708
830
|
const rowCenter = cursorY + line.height / 2
|
|
709
831
|
const lineWidth = line.runs.reduce((sum, run) => sum + run.w, 0)
|
|
710
832
|
|
|
711
|
-
// 对齐:center 居中(扣掉行尾整角标点占的空白),left
|
|
833
|
+
// 对齐:center 居中(扣掉行尾整角标点占的空白),left 从面板左内侧起排(卡头再缩进徽章宽度)
|
|
712
834
|
const lineAlign = line.align === 'center' ? 'center' : align
|
|
713
835
|
const trim = lineAlign === 'center' ? trailingPunctWidth(line.runs) : 0
|
|
836
|
+
const indent = line.indent || 0
|
|
714
837
|
let x = lineAlign === 'center'
|
|
715
838
|
? panelRect.x + panelRect.w / 2 - (lineWidth - trim) / 2
|
|
716
|
-
: panelRect.x + paddingX
|
|
839
|
+
: panelRect.x + paddingX + indent
|
|
717
840
|
|
|
718
841
|
if (line.isStars) {
|
|
719
842
|
// 整行星:五角星在面板里居中(paintStarSprite 的 x 参数是第 1 颗星的左沿)
|
|
@@ -724,6 +847,12 @@ async function renderFortune (ctx, options) {
|
|
|
724
847
|
continue
|
|
725
848
|
}
|
|
726
849
|
|
|
850
|
+
// 卡头左边的圆角徽章
|
|
851
|
+
if (line.badge) {
|
|
852
|
+
drawBadge(g, panelRect.x + paddingX, rowCenter,
|
|
853
|
+
line.badge.size, line.badge.color, line.badge.text, currentFamilies)
|
|
854
|
+
}
|
|
855
|
+
|
|
727
856
|
for (const run of line.runs) {
|
|
728
857
|
if (run.sprite) {
|
|
729
858
|
paintStarSprite(g, run, x, rowCenter)
|
|
@@ -735,6 +864,23 @@ async function renderFortune (ctx, options) {
|
|
|
735
864
|
g.fillText(run.char, x, rowCenter)
|
|
736
865
|
x += run.w
|
|
737
866
|
}
|
|
867
|
+
|
|
868
|
+
// 卡头下方的分隔线(只在后面还有内容时画)
|
|
869
|
+
if (line.divider) {
|
|
870
|
+
const left = panelRect.x + paddingX + indent
|
|
871
|
+
const right = panelRect.x + panelRect.w - paddingX
|
|
872
|
+
const y = cursorY + line.height * 0.98
|
|
873
|
+
g.save()
|
|
874
|
+
// 0.28 在宣纸上几乎看不见,加到 0.5 才有参考图那种分隔感
|
|
875
|
+
g.strokeStyle = withAlpha(line.badge ? line.badge.dividerColor : colors.text, 0.5)
|
|
876
|
+
g.lineWidth = Math.max(1, line.font * 0.06)
|
|
877
|
+
g.beginPath()
|
|
878
|
+
g.moveTo(left, y)
|
|
879
|
+
g.lineTo(right, y)
|
|
880
|
+
g.stroke()
|
|
881
|
+
g.restore()
|
|
882
|
+
}
|
|
883
|
+
|
|
738
884
|
cursorY += line.height
|
|
739
885
|
}
|
|
740
886
|
|
package/package.json
CHANGED
package/assets/bg.png
DELETED
|
Binary file
|