quote-api / test-real.js
Quote API Deploy
feat: deploy quote-api to Hugging Face Spaces
2413e0a unverified
Raw
History Blame Contribute Delete
17.2 kB
// Real rendering test using actual drawMultilineText
// Run: node test-real.js
const fs = require('fs')
const path = require('path')
const { createCanvas } = require('canvas')
const { drawQuote } = require('./utils/quote-generate/composer')
const { loadFonts, gradientTint } = require('./utils/quote-generate/index')
const { avatarImageLetters } = require('./utils/quote-generate/avatar')
const { drawAvatar } = require('./utils/quote-generate/avatar')
const { AVATAR_COLORS } = require('./utils/quote-generate/constants')
const { colorLuminance } = require('./utils/quote-generate/color')
const { loadIcons, drawVoiceRow, drawDocumentRow, drawAudioRow } = require('./utils/quote-generate/attachments')
const OUT = path.join(__dirname, 'test-output')
fs.mkdirSync(OUT, { recursive: true })
function makeAvatar (color1, color2, letter) {
const size = 100
const canvas = createCanvas(size, size)
const ctx = canvas.getContext('2d')
const grad = ctx.createLinearGradient(0, 0, size, size)
grad.addColorStop(0, color1)
grad.addColorStop(1, color2)
ctx.fillStyle = grad
ctx.beginPath()
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#fff'
ctx.font = `bold ${size * 0.45}px sans-serif`
ctx.textAlign = 'center'
ctx.fillText(letter, size / 2, size / 2 + size * 0.16)
return canvas
}
async function main () {
await loadFonts()
await loadIcons()
const { drawMultilineText } = require('./utils/quote-generate/text-renderer')
const scale = 2
const width = 512 * scale
const height = 512 * scale
const tg = { getCustomEmojiStickers: async () => [] }
// Mirrors the production scale in utils/quote-generate/index.js
const nameSize = 18 * scale
const fontSize = 24 * scale
const replyNameSize = 14 * scale
const replyTextSize = 15 * scale
async function mkName (text, color) {
const canvas = await drawMultilineText(text, [{ type: 'bold', offset: 0, length: text.length }], nameSize, color, 0, nameSize, width, nameSize, 'apple', tg)
return gradientTint(canvas, color, colorLuminance(color, 0.25))
}
async function mkText (text, color) {
return drawMultilineText(text, [], fontSize, color || '#fff', 0, fontSize, width, height, 'apple', tg)
}
async function mkReplyName (text, color) {
return drawMultilineText(text, 'bold', replyNameSize, color, 0, replyNameSize, width * 0.9, replyNameSize, 'apple', tg)
}
async function mkReplyText (text, color) {
return drawMultilineText(text, [], replyTextSize, color || '#fff', 0, replyTextSize, width * 0.9, replyTextSize, 'apple', tg)
}
const bg = { colorOne: '#292232', colorTwo: '#292232', textColor: '#fff' }
const bgLight = { colorOne: '#e8e8e8', colorTwo: '#e8e8e8', textColor: '#000' }
const bgGrad = { colorOne: '#667eea', colorTwo: '#764ba2', textColor: '#fff' }
const tests = []
// 1. Basic dark
tests.push({
name: 'real-01-basic',
opts: {
scale, background: bg,
avatar: makeAvatar('#FF885E', '#FF516A', 'A'),
name: await mkName('assasinfil', '#FF8E86'),
text: await mkText('Переполнение вошло в чат'),
nameColor: '#FF8E86'
}
})
// 2. Basic light
tests.push({
name: 'real-02-light',
opts: {
scale, background: bgLight,
avatar: makeAvatar('#72D5FD', '#2A9EF1', 'N'),
name: await mkName('nowiks', '#3CA5EC'),
text: await mkText('Ну получить можно... не понял..', '#000'),
nameColor: '#3CA5EC'
}
})
// Debug reply canvas sizes
const dbgReplyName = await mkReplyName('Виталий', '#B18FFF')
const dbgReplyText = await mkReplyText('не понял..')
console.log(`Reply name canvas: ${dbgReplyName.width}x${dbgReplyName.height}`)
console.log(`Reply text canvas: ${dbgReplyText.width}x${dbgReplyText.height}`)
// 3. Reply
tests.push({
name: 'real-03-reply',
opts: {
scale, background: { colorOne: '#1a1a2e', colorTwo: '#16213e', textColor: '#fff' },
avatar: makeAvatar('#FF885E', '#FF516A', 'A'),
reply: {
name: await mkReplyName('Виталий', '#B18FFF'),
nameColor: '#B18FFF',
text: await mkReplyText('не понял..')
},
name: await mkName('assasinfil', '#FF8E86'),
text: await mkText('Переполнение вошло в чат'),
nameColor: '#FF8E86'
}
})
// 4. No avatar, no name (continuation)
tests.push({
name: 'real-04-noname',
opts: {
scale, background: bg,
avatar: null, name: null,
text: await mkText('понял..')
}
})
// 5. Gradient bg
tests.push({
name: 'real-05-gradient',
opts: {
scale, background: bgGrad,
avatar: makeAvatar('#FFCD6A', '#FFA85C', 'Y'),
name: await mkName('Yuri', '#FFCD6A'),
text: await mkText('This is a gradient background test with a longer message that wraps across multiple lines'),
nameColor: '#FFCD6A'
}
})
// 6. Forward
tests.push({
name: 'real-06-forward',
opts: {
scale, background: bg,
avatar: makeAvatar('#A0DE7E', '#54CB68', 'P'),
name: await mkName('Pavel Durov', '#4DD6BF'),
text: await mkText('Telegram is the most secure messenger in the world.'),
isForward: true,
forwardLabel: 'Forwarded from Durov\'s Channel',
nameColor: '#4DD6BF'
}
})
// 7. Forward + reply
tests.push({
name: 'real-07-fwd-reply',
opts: {
scale, background: { colorOne: '#1e1e2e', colorTwo: '#1e1e2e', textColor: '#fff' },
avatar: makeAvatar('#53EDD6', '#28C9B7', 'M'),
reply: {
name: await mkReplyName('Alice', '#7AC9FF'),
nameColor: '#7AC9FF',
text: await mkReplyText('What did he say?')
},
name: await mkName('Mike', '#45E8D1'),
text: await mkText('He said something very interesting about the project roadmap'),
isForward: true,
forwardLabel: 'Forwarded from Project Updates',
nameColor: '#45E8D1'
}
})
// 8. Sender tag
tests.push({
name: 'real-08-tag',
opts: {
scale, background: bg,
avatar: makeAvatar('#FF885E', '#FF516A', 'A'),
name: await mkName('Admin User', '#FF8E86'),
text: await mkText('Welcome to the group! Please read the rules.'),
nameColor: '#FF8E86',
senderTag: 'Admin'
}
})
// 9. Tag + forward
tests.push({
name: 'real-09-tag-fwd',
opts: {
scale, background: { colorOne: '#0d1117', colorTwo: '#161b22', textColor: '#e6edf3' },
avatar: makeAvatar('#FFA8A8', '#FF719A', 'C'),
name: await mkName('Channel Bot', '#FF7FD5'),
text: await mkText('Important announcement: server maintenance scheduled for tonight at 3 AM UTC'),
isForward: true,
forwardLabel: 'Forwarded from System Alerts',
nameColor: '#FF7FD5',
senderTag: 'NFC/SubGHz Dev'
}
})
// 10. Long text with reply
tests.push({
name: 'real-10-long-reply',
opts: {
scale, background: { colorOne: '#2d2d44', colorTwo: '#2d2d44', textColor: '#fff' },
avatar: makeAvatar('#72D5FD', '#2A9EF1', 'L'),
reply: {
name: await mkReplyName('Previous User', '#3CA5EC'),
nameColor: '#3CA5EC',
text: await mkReplyText('Can you explain?')
},
name: await mkName('Long Text User', '#7AC9FF'),
text: await mkText('So basically what happens is that the system processes the input through several stages. First it validates the data, then transforms it, and finally persists it to the database.'),
nameColor: '#7AC9FF'
}
})
// 11. Short text
tests.push({
name: 'real-11-short',
opts: {
scale, background: bg,
avatar: makeAvatar('#FFCD6A', '#FFA85C', 'S'),
name: await mkName('User', '#FFA357'),
text: await mkText('ok'),
nameColor: '#FFA357'
}
})
// 12. Long name + tag
tests.push({
name: 'real-12-longname-tag',
opts: {
scale, background: bg,
avatar: makeAvatar('#E0A2F3', '#D669ED', 'X'),
name: await mkName('Alexander Superlong Name', '#B18FFF'),
text: await mkText('Testing a very long name with a tag'),
nameColor: '#B18FFF',
senderTag: 'Lead Developer & Architect'
}
})
// 13. Partial quote (isQuote)
tests.push({
name: 'real-13-quote',
opts: {
scale, background: bg,
avatar: makeAvatar('#72D5FD', '#2A9EF1', 'Q'),
reply: {
name: await mkReplyName('Original Author', '#7AC9FF'),
nameColor: '#7AC9FF',
text: await mkReplyText('only this part was quoted')
},
name: await mkName('Quoter', '#45E8D1'),
text: await mkText('Exactly, this part is the key insight'),
nameColor: '#45E8D1',
isQuote: true
}
})
// 14. Reply no avatar
tests.push({
name: 'real-14-reply-noavatar',
opts: {
scale, background: bg,
avatar: null,
reply: {
name: await mkReplyName('Someone', '#FF8E86'),
nameColor: '#FF8E86',
text: await mkReplyText('Original message text here')
},
name: null,
text: await mkText('Reply to that')
}
})
// Helper: make circular avatar from avatarImageLetters (same as drawAvatar does)
function makeRealAvatar (letters, colorIndex) {
const avatarCanvas = avatarImageLetters(letters, AVATAR_COLORS[colorIndex % 7])
const size = avatarCanvas.height
const circle = createCanvas(size, size)
const cctx = circle.getContext('2d')
cctx.beginPath()
cctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2, true)
cctx.clip()
cctx.drawImage(avatarCanvas, 0, 0, size, size)
return circle
}
// 16. Initials avatar — two letters
tests.push({
name: 'real-16-initials-2',
opts: {
scale, background: bg,
avatar: makeRealAvatar('YL', 3),
name: await mkName('Yuri Ly', '#4DD6BF'),
text: await mkText('Testing two-letter initials avatar'),
nameColor: '#4DD6BF'
}
})
// 17. Initials avatar — one letter
tests.push({
name: 'real-17-initials-1',
opts: {
scale, background: bg,
avatar: makeRealAvatar('А', 5),
name: await mkName('Антон', '#B18FFF'),
text: await mkText('Тестуємо аватарку з одною літерою'),
nameColor: '#B18FFF'
}
})
// 18. Initials avatar — Cyrillic two letters
tests.push({
name: 'real-18-initials-cyrillic',
opts: {
scale, background: bgLight,
avatar: makeRealAvatar('ВН', 2),
name: await mkName('Виктор Никитчук', '#3CA5EC'),
text: await mkText('Кириллические буквы в аватарке'),
nameColor: '#3CA5EC'
}
})
// 19. Standalone avatar renders (for direct comparison)
const avatarTests = [
{ letters: 'YL', idx: 0 },
{ letters: 'А', idx: 1 },
{ letters: 'ВН', idx: 2 },
{ letters: 'JD', idx: 3 },
{ letters: 'М', idx: 4 },
{ letters: '?', idx: 5 },
{ letters: 'PD', idx: 6 }
]
const avSize = 200
const avPad = 20
const avRow = createCanvas(avatarTests.length * (avSize + avPad), avSize)
const avCtx = avRow.getContext('2d')
for (let i = 0; i < avatarTests.length; i++) {
const av = makeRealAvatar(avatarTests[i].letters, avatarTests[i].idx)
avCtx.drawImage(av, i * (avSize + avPad), 0, avSize, avSize)
}
fs.writeFileSync(path.join(OUT, 'real-19-avatars-row.png'), avRow.toBuffer('image/png'))
console.log(` ✓ real-19-avatars-row (${avRow.width}x${avRow.height})`)
// 20. Voice message
tests.push({
name: 'real-20-voice',
opts: {
scale, background: bg,
avatar: makeAvatar('#FF885E', '#FF516A', 'A'),
name: await mkName('assasinfil', '#FF8E86'),
attachment: { canvas: drawVoiceRow(Array.from({ length: 50 }, (_, i) => 6 + ((i * 13) % 26), 0), 83, '#FF8E86', '#fff', scale, width * 2 / 3) },
nameColor: '#FF8E86'
}
})
// 21. Document
tests.push({
name: 'real-21-document',
opts: {
scale, background: bg,
avatar: makeAvatar('#72D5FD', '#2A9EF1', 'N'),
name: await mkName('nowiks', '#7AC9FF'),
attachment: { canvas: drawDocumentRow({ file_name: 'квартальний_звіт_2026.pdf', file_size: 2.4 * 1024 * 1024 }, '#7AC9FF', '#fff', scale, width * 2 / 3) },
nameColor: '#7AC9FF'
}
})
// 22. Audio (no cover → note disc)
tests.push({
name: 'real-22-audio',
opts: {
scale, background: bg,
avatar: makeAvatar('#A0DE7E', '#54CB68', 'M'),
name: await mkName('Музика', '#4DD6BF'),
attachment: { canvas: drawAudioRow({ title: 'Водограй', performer: 'Володимир Івасюк', duration: 215 }, '#4DD6BF', '#fff', scale, width * 2 / 3) },
nameColor: '#4DD6BF'
}
})
// 23. Video with play badge + duration, plus caption
const videoFrame = createCanvas(800, 450)
{
const vctx = videoFrame.getContext('2d')
const grad = vctx.createLinearGradient(0, 0, 800, 450)
grad.addColorStop(0, '#2b5876')
grad.addColorStop(1, '#4e4376')
vctx.fillStyle = grad
vctx.fillRect(0, 0, 800, 450)
}
tests.push({
name: 'real-23-video',
opts: {
scale, background: bg,
avatar: makeAvatar('#FFCD6A', '#FFA85C', 'V'),
name: await mkName('Відеограф', '#FFA357'),
media: { canvas: videoFrame, type: 'video', maxSize: width * 2 / 3, badge: { play: true, label: '0:42' } },
text: await mkText('подивіться до кінця 🔥'),
nameColor: '#FFA357'
}
})
// 24. GIF (no caption → flush media with GIF chip)
const gifFrame = createCanvas(640, 360)
{
const gctx = gifFrame.getContext('2d')
const grad = gctx.createLinearGradient(0, 360, 640, 0)
grad.addColorStop(0, '#f83600')
grad.addColorStop(1, '#f9d423')
gctx.fillStyle = grad
gctx.fillRect(0, 0, 640, 360)
}
tests.push({
name: 'real-24-gif',
opts: {
scale, background: bg,
avatar: makeAvatar('#E0A2F3', '#D669ED', 'G'),
name: await mkName('Гіфка', '#B18FFF'),
media: { canvas: gifFrame, type: 'animation', maxSize: width * 2 / 3, badge: { label: 'GIF' } },
nameColor: '#B18FFF'
}
})
// Generate single tests
for (const test of tests) {
try {
const canvas = drawQuote(test.opts)
fs.writeFileSync(path.join(OUT, `${test.name}.png`), canvas.toBuffer('image/png'))
console.log(` ✓ ${test.name} (${canvas.width}x${canvas.height})`)
} catch (err) {
console.error(` ✗ ${test.name}: ${err.message}`)
}
}
// 15. MULTI-MESSAGE DIALOG
const dialogOpts = [
{
scale, background: bg,
avatar: null, // avatar rides on the LAST message of a streak
name: await mkName('assasinfil', '#FF8E86'),
text: await mkText('Хто знає як зробити NFC клон?'),
nameColor: '#FF8E86',
senderTag: 'NFC/SubGHz Dev',
groupPos: 'first'
},
{
scale, background: bg,
avatar: makeAvatar('#FF885E', '#FF516A', 'A'),
name: null,
text: await mkText('Треба Flipper Zero для цього'),
groupPos: 'last'
},
{
scale, background: bg,
avatar: makeAvatar('#72D5FD', '#2A9EF1', 'N'),
reply: {
name: await mkReplyName('assasinfil', '#FF8E86'),
nameColor: '#FF8E86',
text: await mkReplyText('Хто знає як зробити NFC клон?')
},
name: await mkName('nowiks', '#7AC9FF'),
text: await mkText('Ну получить можно... не понял..'),
nameColor: '#7AC9FF',
isQuote: true
},
{
scale, background: bg,
avatar: makeAvatar('#E0A2F3', '#D669ED', 'В'),
name: await mkName('Виктор Никитчук', '#B18FFF'),
text: await mkText('Звучит очень по гейски'),
isForward: true,
forwardLabel: 'Forwarded from Misha Myte',
nameColor: '#B18FFF'
},
{
scale, background: bg,
avatar: makeAvatar('#FF885E', '#FF516A', 'A'),
name: await mkName('assasinfil', '#FF8E86'),
text: await mkText('Переполнение вошло в чат'),
nameColor: '#FF8E86',
senderTag: 'NFC/SubGHz Dev'
},
{
scale, background: bg,
avatar: makeAvatar('#72D5FD', '#2A9EF1', 'N'),
name: await mkName('nowiks', '#7AC9FF'),
text: await mkText('понял..'),
nameColor: '#7AC9FF'
}
]
const quoteMargin = 5 * scale
const dialogCanvases = dialogOpts.map(o => drawQuote(o))
let totalW = 0, totalH = 0
for (const c of dialogCanvases) {
if (c.width > totalW) totalW = c.width
totalH += c.height + quoteMargin
}
const dialogCanvas = createCanvas(totalW, totalH)
const dctx = dialogCanvas.getContext('2d')
let y = 0
for (const c of dialogCanvases) {
dctx.drawImage(c, 0, y)
y += c.height + quoteMargin
}
fs.writeFileSync(path.join(OUT, 'real-15-dialog.png'), dialogCanvas.toBuffer('image/png'))
console.log(` ✓ real-15-dialog (${dialogCanvas.width}x${dialogCanvas.height})`)
console.log('\nDone!')
}
main().catch(console.error)