import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas' import { writeFileSync, existsSync, readFileSync } from 'fs' import path from 'path' import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const BULGE_RADIUS = 580 const BULGE_STRENGTH = 0.12 const WIDTH = 720 const HEIGHT = 1280 const FONT_URL = 'https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/tinos/Tinos-Regular.ttf' const EMOJI_JSON_URL = 'https://media.githubusercontent.com/media/Ditzzx-vibecoder/entahlah/main/emoji-apple.json' const FONT_PATH = path.join(__dirname, 'QuoteFont.ttf') const EMOJI_JSON_PATH = path.join(__dirname, 'emoji-apple.json') const THEMES = { quote: { bg: '#ffffff', text: '#9e0a1b' }, } let emojiMap = null const emojiImageCache = new Map() async function downloadFile(url, dest) { const res = await fetch(url) if (!res.ok) throw new Error(`Gagal download: ${url} (${res.status})`) const buf = Buffer.from(await res.arrayBuffer()) writeFileSync(dest, buf) return buf } async function ensureFonts() { if (!existsSync(FONT_PATH)) await downloadFile(FONT_URL, FONT_PATH) GlobalFonts.registerFromPath(FONT_PATH, 'QuoteFont') } function emojiToUnicode(emoji) { return [...emoji].map(c => c.codePointAt(0).toString(16).padStart(4, '0')).join('-') } async function loadEmojiMap() { if (emojiMap) return emojiMap if (!existsSync(EMOJI_JSON_PATH)) await downloadFile(EMOJI_JSON_URL, EMOJI_JSON_PATH) emojiMap = JSON.parse(readFileSync(EMOJI_JSON_PATH, 'utf-8')) return emojiMap } async function getEmojiImage(emoji) { if (emojiImageCache.has(emoji)) return emojiImageCache.get(emoji) const map = await loadEmojiMap() const base = emojiToUnicode(emoji) const variants = [ base, base.replace(/-fe0f/gi, ''), `${base.replace(/-fe0f/gi, '')}-fe0f`, base.toUpperCase(), base.replace(/-fe0f/gi, '').toUpperCase(), base.replace(/-fe0f/gi, '').toUpperCase() + '-FE0F' ] let b64 = null for (const v of variants) { if (map[v]) { b64 = map[v]; break } } if (!b64) return null const img = await loadImage(Buffer.from(b64, 'base64')) emojiImageCache.set(emoji, img) return img } const EMOJI_REGEX = /(\p{Emoji_Modifier_Base}\p{Emoji_Modifier}|\p{Emoji_Presentation}\uFE0F?|\p{Emoji}\uFE0F|[\u{1F1E0}-\u{1F1FF}]{2}|\p{Extended_Pictographic}\uFE0F?)/gu function tokenize(rawText) { const chunks = rawText.split(EMOJI_REGEX) const tokens = [] for (const chunk of chunks) { if (!chunk) continue EMOJI_REGEX.lastIndex = 0 if (EMOJI_REGEX.test(chunk)) { tokens.push({ type: 'emoji', text: chunk }) EMOJI_REGEX.lastIndex = 0 continue } const parts = chunk.split(/(\*[^*]+\*)/g) for (const part of parts) { if (!part) continue if (part.startsWith('*') && part.endsWith('*') && part.length > 2) { splitWords(part.slice(1, -1), 'italic', tokens) } else { splitWords(part, 'auto', tokens) } } } return tokens } function splitWords(text, forcedStyle, tokens) { const words = text.split(/(\s+)/) for (const w of words) { if (w === '') continue if (/^\s+$/.test(w)) { tokens.push({ type: 'space', text: w }) continue } let style = forcedStyle if (forcedStyle === 'auto') { const letters = w.replace(/[^a-zA-Z]/g, '') style = (letters.length > 1 && letters === letters.toUpperCase()) ? 'bold' : 'regular' } tokens.push({ type: 'word', text: w, style }) } } function fontForStyle(style, size) { if (style === 'bold') return `bold ${size}px QuoteFont` if (style === 'italic') return `italic ${size}px QuoteFont` return `${size}px QuoteFont` } function expandToChars(tokens) { const chars = [] for (const tok of tokens) { if (tok.type === 'emoji') { chars.push({ type: 'emoji', text: tok.text }) continue } if (tok.type === 'space') { chars.push({ type: 'char', text: ' ', style: 'regular' }) continue } for (const ch of tok.text) { chars.push({ type: 'char', text: ch, style: tok.style }) } } return chars } function layoutLines(ctx, tokens, fontSize, maxWidth) { const lines = [] let current = [] let currentWidth = 0 function widthOf(tok) { if (tok.type === 'emoji') return fontSize ctx.font = fontForStyle(tok.style, fontSize) return ctx.measureText(tok.text).width } for (const tok of tokens) { if (tok.type === 'space') { ctx.font = `${fontSize}px QuoteFont` const spW = ctx.measureText(' ').width if (current.length === 0) continue current.push({ ...tok, width: spW }) currentWidth += spW continue } const w = widthOf(tok) if (currentWidth + w > maxWidth && current.length > 0) { while (current.length && current[current.length - 1].type === 'space') current.pop() lines.push(current) current = [] currentWidth = 0 } current.push({ ...tok, width: w }) currentWidth += w } if (current.length) { while (current.length && current[current.length - 1].type === 'space') current.pop() lines.push(current) } return lines } function totalLineWidth(line) { return line.reduce((sum, t) => sum + t.width, 0) } function findBestFontSize(ctx, tokens, maxWidth, maxHeight) { let lo = 10, hi = 80, best = lo while (lo <= hi) { const mid = Math.floor((lo + hi) / 2) const currentLineGap = Math.floor(mid * 0.2) const lines = layoutLines(ctx, tokens, mid, maxWidth) const totalHeight = lines.length * (mid + currentLineGap) - currentLineGap const fits = totalHeight <= maxHeight && lines.every(l => totalLineWidth(l) <= maxWidth) if (fits) { best = mid; lo = mid + 1 } else { hi = mid - 1 } } return best } async function drawEmojiToken(ctx, emoji, x, y, size) { const img = await getEmojiImage(emoji) if (!img) { ctx.font = `${size}px QuoteFont`; ctx.fillText(emoji, x, y); return } ctx.drawImage(img, x, y, size, size) } async function drawFlatLines(ctx, lines, centerX, startY, fontSize, lineGap) { let y = startY for (const line of lines) { const charTokens = expandToChars(line) const widths = charTokens.map(c => { if (c.type === 'emoji') return fontSize ctx.font = fontForStyle(c.style, fontSize) return ctx.measureText(c.text).width }) const lineWidth = widths.reduce((a, b) => a + b, 0) let x = centerX - lineWidth / 2 for (let i = 0; i < charTokens.length; i++) { const c = charTokens[i] const w = widths[i] if (c.type === 'emoji') { await drawEmojiToken(ctx, c.text, x, y, fontSize) } else if (c.text !== ' ') { ctx.font = fontForStyle(c.style, fontSize) ctx.fillText(c.text, x, y) } x += w } y += fontSize + lineGap } } function applyBulgeDistortion(srcCtx, dstCtx, width, height, radius, strength) { const srcData = srcCtx.getImageData(0, 0, width, height) const dstData = dstCtx.createImageData(width, height) const src = srcData.data const dst = dstData.data const cx = width / 2 const cy = height / 2 for (let y = 0; y < height; y++) { const dy = y - cy for (let x = 0; x < width; x++) { const dx = x - cx const dist = Math.sqrt(dx * dx + dy * dy) let sx = x let sy = y if (dist < radius && dist > 0) { const norm = dist / radius const exponent = 1 + strength const newDist = radius * Math.pow(norm, exponent) const ratio = newDist / dist sx = cx + dx * ratio sy = cy + dy * ratio } const dstIdx = (y * width + x) * 4 if (sx >= 0 && sx < width - 1 && sy >= 0 && sy < height - 1) { const x1 = Math.floor(sx) const y1 = Math.floor(sy) const x2 = x1 + 1 const y2 = y1 + 1 const wx = sx - x1 const wy = sy - y1 const i11 = (y1 * width + x1) * 4 const i21 = (y1 * width + x2) * 4 const i12 = (y2 * width + x1) * 4 const i22 = (y2 * width + x2) * 4 for (let c = 0; c < 4; c++) { const top = src[i11 + c] * (1 - wx) + src[i21 + c] * wx const bottom = src[i12 + c] * (1 - wx) + src[i22 + c] * wx dst[dstIdx + c] = Math.round(top * (1 - wy) + bottom * wy) } } else { dst[dstIdx] = 255 dst[dstIdx + 1] = 255 dst[dstIdx + 2] = 255 dst[dstIdx + 3] = 255 } } } dstCtx.putImageData(dstData, 0, 0) } async function generateQuoteBuffer(text) { const t = THEMES.quote const padding = 140 const maxWidth = WIDTH - padding * 2 await ensureFonts() await loadEmojiMap() const offCanvas = createCanvas(WIDTH, HEIGHT) const offCtx = offCanvas.getContext('2d') offCtx.fillStyle = t.bg offCtx.fillRect(0, 0, WIDTH, HEIGHT) const tokens = tokenize(text) offCtx.textAlign = 'left' offCtx.textBaseline = 'top' const fontSize = findBestFontSize(offCtx, tokens, maxWidth, HEIGHT * 0.45) const lineGap = Math.floor(fontSize * 0.2) const lines = layoutLines(offCtx, tokens, fontSize, maxWidth) const totalTextHeight = lines.length * (fontSize + lineGap) - lineGap offCtx.fillStyle = t.text const startY = (HEIGHT - totalTextHeight) / 2 const centerX = WIDTH / 2 await drawFlatLines(offCtx, lines, centerX, startY, fontSize, lineGap) const canvas = createCanvas(WIDTH, HEIGHT) const ctx = canvas.getContext('2d') applyBulgeDistortion(offCtx, ctx, WIDTH, HEIGHT, BULGE_RADIUS, BULGE_STRENGTH) return await canvas.encode('png') } let handler = async (m, { conn, text, usedPrefix, command }) => { if (!text) { return m.reply(`❌ Teksnya mana bre?\n\n*Contoh:* ${usedPrefix + command} Hidup itu seperti mengendarai sepeda 🚲.`) } await m.react('⏳') let loadMsg = await conn.sendMessage(m.chat, { text: '🔄 *Membuat Quote...*' }, { quoted: m }) try { const imageBuffer = await generateQuoteBuffer(text) await conn.sendMessage(m.chat, { image: imageBuffer, caption: `✨ *Selesai bre!*\n> © Erine Quote Maker` }, { quoted: m }) await conn.sendMessage(m.chat, { delete: loadMsg.key }) await m.react('✅') } catch (e) { console.error('[QUOTE MAKER ERROR]', e) await conn.sendMessage(m.chat, { text: `❌ *Gagal:* ${e.message}`, edit: loadMsg.key }).catch(() => {}) await m.react('❌') } } handler.help = ['bulgequote '] handler.tags = ['maker'] handler.command = /^(bulgequote)$/i handler.limit = true handler.register = true export default handler