Lynx Snip

maker-bulgequote

#34wibtc

javascript · 356 baris · 36 dilihat

Lihat raw
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 <teks>']
handler.tags = ['maker']
handler.command = /^(bulgequote)$/i
handler.limit = true
handler.register = true

export default handler