0047 文字卡片-Templater

效果展示

2022-11-03 Thur. 07_19_23.png

有点大,被放大了会显得不那么精致,其实还行啦,我主要是用在微信群里吸引眼球,因为你直接发一大段文字大概率没人读。

这个东西不完美,但是能用,所以如果你想用,请先完整阅读说明。

代码

Scripts/get_tweet_card.js

就是 Templater 的脚本文件夹里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
/** @type {object} 一些设定,可以修改,但不推荐 */
const opt = {
width: 720,
margin: 60,
padding: 48,
fontSize: 36,
lineHeight: 54,
indent: 72,
paragraphsMarginBottom: 18,
fontFamily: 'Menlo, SFMono-Regular, Consolas, "Roboto Mono", "Source Code Pro", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Microsoft YaHei", sans-serif'
}
/** 基于上方属性计算得出 */
opt.cardWidth = opt.width - opt.margin*2
opt.contentWidth = opt.cardWidth - opt.padding*2
opt.logoSize = 2*opt.fontSize
opt.paddingLR = opt.padding
opt.paddingTB = opt.padding
opt.marginLR = opt.margin
opt.marginTB = opt.margin
opt.smallFontSize = opt.fontSize * 0.6
opt.contentMarginLR = opt.marginLR+opt.paddingLR
opt.contentMarginTB = opt.marginTB+opt.paddingTB

/**
* 数字两位化
*
* @param {number} num 0~99 的整数
* @returnn {string}
*/
const dbNum = num => (num > 9 ? String(num) : '0' + num);
/** @type {array} */
const daysName = ['Sun.', 'Mon.', 'Tues.', 'Wed.', 'Thur.', 'Fri.', 'Sat.']
/**
* 获取当前时间字符串
*
* @return {string}
*/
const getNowTime = () => {
const now = new Date()
const t = {
YYYY : now.getFullYear(),
MM: dbNum(now.getMonth()+1),
DD: dbNum(now.getDate()),
hh: dbNum(now.getHours()),
mm: dbNum(now.getMinutes()),
ss: dbNum(now.getSeconds()),
EE: daysName[now.getDay()]
}
return `${t.YYYY}-${t.MM}-${t.DD} ${t.EE} ${t.hh}:${t.mm}:${t.ss}`
}
// 创建画布对象
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')

/**
* 画布文字逐行分割
*
* @param {object} ctx 画布上下文对象
* @param {string} text 要写入的文字内容
* @param {number} width 文字内容在画布中占据的宽度
* @return {array} 二维数组,第1层是段落,第2层是段落中的每一行
*/
const canvasTextSplit = (text, width) => {
text = text.trim()
if(text.length === 0) return []
const result = []
// 先进行段落的分割
const paragraphArray = text.replace(/(\r?\n\s*)+/g, '\n').split(/\s*\r?\n\s*/g)
for(const p of paragraphArray){
const linesInParagraph = []
let nowLetter = 0
for (let i = 0; i <= p.length; i++) {
const thisLineWidth = linesInParagraph.length ? width : width-opt.indent
if (ctx.measureText(p.substring(nowLetter, i)).width > thisLineWidth) {
linesInParagraph.push(p.substring(nowLetter, i-1))
nowLetter = i-1
}else if(i === p.length){
linesInParagraph.push(p.substring(nowLetter, i))
}
}
result.push(linesInParagraph)
}
return result
}
/**
* 将段落数组中的文字绘制到画布
*
* @param {object} ctx 画布上下文对象
* @param {array} paragraphs 二维数组,第1层是段落,第2层是段落中的每一行
* @param {number} startX 起始的横坐标
* @param {number} startY 起始的纵坐标
* @param {number} opt.lineHeight 行高
* @return {number} 结束位置的纵坐标
*/
const drawText = async(paragraphs, startX, startY) => {
let thisLineY = startY
paragraphs.forEach((p, pIndex) => {
p.forEach((line, lIndex)=>{
const thisLineX = lIndex ? startX : startX + opt.indent
thisLineY += opt.lineHeight
ctx.fillText(line, thisLineX, thisLineY)
})
thisLineY += opt.paragraphsMarginBottom
})
return thisLineY
}
/**
* 计算绘制文字所需要占据的高度
*
* @param {array} paragraphs 二维数组,第1层是段落,第2层是段落中的每一行
* @param {number} opt.lineHeight 行高
* @return {number} 文字内容所占据的高度
*/
const textNeedHeight = (paragraphs)=>{
return (paragraphs.length-1) * opt.paragraphsMarginBottom
+ paragraphs.flat().length * opt.lineHeight
}
/**
* 将 base64 格式的图片转换为 Blob 格式数据
*
* @param {string} dataUrl base64 格式的数据地址
* @return {object} Blob 格式的图片数据
*/
const dataURLtoBlob = dataUrl=>{
const dataArr = dataUrl.split(',');
const mime = dataArr[0].match(/:(.*?);/)[1];
const bStr = atob(dataArr[1]);
let n = bStr.length;
const uint8Arr = new Uint8Array(n);
while(n--){
uint8Arr[n] = bStr.charCodeAt(n);
}
return new Blob([uint8Arr], {type:mime});
}
/**
* 将画布保存为图片并自动进行下载
*
* @param {object} canvas 画布对象
* @param {string} name 保存的文件名
* @param {string} [type="png"] 文件图片的格式: png、jpeg、gif
*/
const downloadImgFromCanvas = (name, type="png")=>{
if(type==='jpg') type = 'jpeg'
const imgDataUrl = canvas.toDataURL('image/'+type)
// const imgData = canvas.toDataURL({format: 'png', quality:1, width:20000, height:4000})
const blob = dataURLtoBlob(imgDataUrl)
const blobUrl = URL.createObjectURL(blob)

const imgDownloadLink = document.createElement('a')
imgDownloadLink.download = name+'.'+(type==='jpeg' ? 'jpg' : type)
imgDownloadLink.href = blobUrl
imgDownloadLink.click();
}
/**
* 重置画布对象
*
* @param {number} height 画布的高度
* @param {string} fillColor 画布填充的背景颜色
*/
const canvasRest =(height, fillColor)=>{
canvas.width = opt.width
canvas.height = height
ctx.fillStyle = fillColor
ctx.fillRect(0, 0, canvas.width, canvas.height)
}

/**
* 绘制圆角矩形
*
* @param {number} x
* @param {number} y
* @param {number} w
* @param {number} h
* @param {number} r
*/
const drawRoundedRect = (x, y, w, h, r)=>{
var ptA = { x: x + r, y: y }
var ptB = { x: x + w, y: y }
var ptC = { x: x + w, y: y + h }
var ptD = { x: x, y: y + h }
var ptE = { x: x, y: y }

ctx.beginPath();

ctx.moveTo(ptA.x, ptA.y);
ctx.arcTo(ptB.x, ptB.y, ptC.x, ptC.y, r);
ctx.arcTo(ptC.x, ptC.y, ptD.x, ptD.y, r);
ctx.arcTo(ptD.x, ptD.y, ptE.x, ptE.y, r);
ctx.arcTo(ptE.x, ptE.y, ptA.x, ptA.y, r);

ctx.closePath()
// ctx.stroke();
ctx.fill()
}

const loadImage = (url, l, t) => new Promise( resolve => {
const img = new Image()
img.onload = () => {
ctx.drawImage(img, l, t, opt.logoSize, opt.logoSize)
return resolve(true)
}
img.src = url
});

const bgGradient = ctx.createLinearGradient(0, 0, opt.width, opt.width/8);
bgGradient.addColorStop(0, "#ffafbd");
bgGradient.addColorStop(1, "#ffc3a0");

/**
*
*
* @param {*} tp
* @return {*}
*/
async function get_tweet_card(tp, logo='', name='稻米鼠', userId='@Dao_Mouse'){
/** @type {string} 获取输入 */
const inputContent = await tp.system.prompt('输入内容', '', false, true)
if(!inputContent) return ''

ctx.font = 'normal '+opt.fontSize+'px '+opt.fontFamily
ctx.textAlign = "left"
ctx.fillStyle = "#333336"
/** 整理内容,计算尺寸 */
const contentArr = canvasTextSplit(inputContent, opt.contentWidth)
opt.contentHeight = textNeedHeight(contentArr)
opt.cardHeight = opt.contentHeight
+ opt.padding*2
+ opt.logoSize
+ opt.lineHeight /** 用来书写时间 */
+ 2*opt.paragraphsMarginBottom /** 放在内容上下 */
/** 初始化画布 */
ctx.shadowBlur = 0
ctx.shadowColor = "rgba(0,0,0,0)"
canvasRest(opt.cardHeight+2*opt.marginTB, bgGradient)
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
ctx.shadowBlur = opt.margin*0.6
ctx.shadowColor = "rgba(0, 0, 0, .3)"
ctx.fillStyle = 'rgba(255, 255, 255, .8)'
drawRoundedRect(opt.marginLR, opt.marginTB, opt.cardWidth, opt.cardHeight, 18)

ctx.font = 'normal '+opt.fontSize+'px '+opt.fontFamily
ctx.textAlign = "left"
ctx.fillStyle = "#333336"
ctx.shadowBlur = opt.fontSize/20
ctx.shadowColor = "rgba(0, 0, 0, .2)"
drawText(contentArr, opt.contentMarginLR, opt.contentMarginTB+opt.logoSize+opt.paragraphsMarginBottom)

ctx.font = '700 '+opt.smallFontSize+'px '+opt.fontFamily
ctx.fillText(name, opt.contentMarginLR+opt.logoSize+opt.smallFontSize, opt.contentMarginTB+opt.logoSize/2);
ctx.font = '200 '+opt.smallFontSize+'px '+opt.fontFamily
ctx.fillText(userId, opt.contentMarginLR+opt.logoSize+opt.smallFontSize, opt.contentMarginTB+opt.logoSize/2+opt.smallFontSize*1.2);

const nowTime = getNowTime()
ctx.textAlign = "right"
ctx.fillText(nowTime, opt.width-opt.marginLR-opt.paddingLR, canvas.height-opt.marginTB-opt.paddingTB);

await loadImage(logo, opt.contentMarginLR, opt.contentMarginTB)
await loadImage(`data:image/png;base64,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`, canvas.width-opt.marginLR-opt.paddingLR/2-opt.logoSize, opt.marginTB+opt.paddingTB/2)
downloadImgFromCanvas(nowTime)
return '![]('+canvas.toDataURL('image/png')+')'
}

module.exports = get_tweet_card;

Templater/生成哔哔卡片.md

1
<% tp.user.get_tweet_card(tp, `头像地址`, '用户名', '@id') %>

模板说明

  • 头像应该是一张正方形图片,推荐 png,这样你可以在里面放圆形的头像或者什么。
  • 头像图片 72*72 像素即可,用 https://c.runoob.com/front-end/59/ 转换为 base64 字符串,然后填入上方模板,注意要有引号
  • 用户名,id 按需填写

使用方法

  • 新建一个笔记,不要用已有笔记,否则你哭了我不管哄
  • 选择插入此模板,填写内容,回车即可
  • 如果是在电脑上,应该可以弹出保存对话框,把图片保存到你喜欢的位置
  • 同时,这张图片被插入笔记中(别看源码,太长),把笔记删掉就可以了
  • 在 Android 手机上,不会弹出下载,可以将笔记切换到阅读模式,然后用 Fooview 提取这张图片,实测很便捷。
  • 苹果手机……我没有,我也不知道会怎样,该怎么做

更多细节

输入框里要用 Shift+Enter 进行换行,如果直接 Enter,恭喜你,卡片已生成,文字木有了。所以推荐先写好,然后复制粘贴。

在手机中可能直接无法换行,所以复制粘贴是最正确的使用方法。

只能纯文字分段输出,默认段首缩进两个字,适合长文字展示。有时候段尾可能丢字,懒得改了(不知道发生了啥),你多加个标点啥的应该能好。