Download test-real.js from kavion/quote-api: direct link, hf CLI and curl.
- Browser
- Download file 17.2 kB
-
https://huggingface.co/spaces/kavion/quote-api/resolve/main/test-real.js
- Command line
-
hf download hf://spaces/kavion/quote-api/test-real.js
-
curl -L -o test-real.js https://huggingface.co/spaces/kavion/quote-api/resolve/main/test-real.js
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) | |