UNPKG

qc-generator-whatsapp

Version:

Advanced generator for dynamic quote images and animations for WhatsApp

1,538 lines (1,533 loc) 50.2 kB
/** * quote_generator.js (Versi Refactoring) * Versi ini difokuskan pada pembuatan gambar dengan format PNG dari path/Buffer untuk bot whatsapp... * Versi ini adalah hasil modifikasi dari repo: https://github.com/LyoSU/quote-api * special thanks to @LyoSU and all contributors on his repo. **/ const fs = require("fs"); const path = require("path"); const runes = require("runes"); const sharp = require("sharp"); const EmojiDbLib = require("emoji-db"); const { LRUCache } = require("lru-cache"); const emojiDb = new EmojiDbLib({ useDefaultDb: true }); const emojiImageByBrandPromise = require("emoji-cache"); const ALLOWED_MEDIA_DIRECTORY = path.resolve(__dirname, "../"); const { createCanvas, loadImage, registerFont } = require("canvas"); async function loadFont() { const fontsDir = path.join(__dirname, "../fonts"); if (!fs.existsSync(fontsDir)) { console.error( `PENTING: Direktori font tidak ditemukan di '${path.resolve(fontsDir)}'.` ); return; } try { const files = await fs.promises.readdir(fontsDir); if (!files || files.length === 0) { console.error( `Tidak ada font yang ditemukan di direktori '${path.resolve( fontsDir )}'.` ); return; } for (const file of files) { try { registerFont(path.join(fontsDir, file), { family: file.replace(/\.[^/.]+$/, ""), }); } catch (error) { console.error(`Gagal memuat font: ${path.join(fontsDir, file)}.`); } } } catch (err) { console.error("Gagal membaca direktori font:", err); } } const fontsLoadedPromise = loadFont(); const avatarCache = new LRUCache({ max: 20, ttl: 1000 * 60 * 5, }); function _normalizeColor(color) { const canvas = createCanvas(0, 0); const canvasCtx = canvas.getContext("2d"); canvasCtx.fillStyle = color; return canvasCtx.fillStyle; } function _colorLuminance(hex, lum) { hex = String(hex).replace(/[^0-9a-f]/gi, ""); if (hex.length < 6) { hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; } lum = lum || 0; let rgb = "#", c, i; for (i = 0; i < 3; i++) { c = parseInt(hex.substr(i * 2, 2), 16); c = Math.round(Math.min(Math.max(0, c + c * lum), 255)).toString(16); rgb += ("00" + c).substr(c.length); } return rgb; } function _hexToRgb(hex) { return hex .replace( /^#?([a-f\d])([a-f\d])([a-f\d])$/i, (m, r, g, b) => "#" + r + r + g + g + b + b ) .substring(1) .match(/.{2}/g) .map((x) => parseInt(x, 16)); } class ColorContrast { constructor() { this.brightnessThreshold = 175; } hexToRgb(hex) { return _hexToRgb(hex); } rgbToHex([r, g, b]) { return `#${r.toString(16).padStart(2, "0")}${g .toString(16) .padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; } getBrightness(color) { const [r, g, b] = this.hexToRgb(color); return (r * 299 + g * 587 + b * 114) / 1000; } adjustBrightness(color, amount) { const [r, g, b] = this.hexToRgb(color); const newR = Math.max(0, Math.min(255, r + amount)); const newG = Math.max(0, Math.min(255, g + amount)); const newB = Math.max(0, Math.min(255, b + amount)); return this.rgbToHex([newR, newG, newB]); } getContrastRatio(background, foreground) { const brightness1 = this.getBrightness(background); const brightness2 = this.getBrightness(foreground); const lightest = Math.max(brightness1, brightness2); const darkest = Math.min(brightness1, brightness2); return (lightest + 0.05) / (darkest + 0.05); } adjustContrast(background, foreground) { const contrastRatio = this.getContrastRatio(background, foreground); const brightnessDiff = this.getBrightness(background) - this.getBrightness(foreground); if (contrastRatio >= 4.5) { return foreground; } else if (brightnessDiff >= 0) { const amount = Math.ceil( (this.brightnessThreshold - this.getBrightness(foreground)) / 2 ); return this.adjustBrightness(foreground, amount); } else { const amount = Math.ceil( (this.getBrightness(foreground) - this.brightnessThreshold) / 2 ); return this.adjustBrightness(foreground, -amount); } } } class QuoteGenerate { constructor() {} async avatarImageletters(letters, color) { const size = 500; const canvas = createCanvas(size, size); const context = canvas.getContext("2d"); const gradient = context.createLinearGradient( 0, 0, canvas.width, canvas.height ); gradient.addColorStop(0, color[0]); gradient.addColorStop(1, color[1]); context.fillStyle = gradient; context.fillRect(0, 0, canvas.width, canvas.height); const drawLetters = await this.drawMultilineText( letters, null, size / 2, "#FFF", 0, size, size * 5, size * 5 ); context.drawImage( drawLetters, (canvas.width - drawLetters.width) / 2, (canvas.height - drawLetters.height) / 1.5 ); return canvas.toBuffer(); } async downloadAvatarImage(user) { const cacheKey = user.id; const avatarImageCache = avatarCache.get(cacheKey); if (avatarImageCache) { return avatarImageCache; } let avatarImage; try { if (!user.photo || (!user.photo.path && !user.photo.buffer)) { throw new Error( "Tidak ada sumber foto (path/buffer), gunakan fallback inisial." ); } let imageSource; if (user.photo.buffer) { imageSource = user.photo.buffer; } else { const requestedPath = path.resolve(user.photo.path); if (!requestedPath.startsWith(ALLOWED_MEDIA_DIRECTORY)) { console.error(`Akses path ditolak untuk avatar: ${user.photo.path}`); throw new Error("Invalid avatar path specified."); } imageSource = requestedPath; } avatarImage = await loadImage(imageSource); } catch (error) { let nameletters; if (user.first_name && user.last_name) { nameletters = runes(user.first_name)[0] + (runes(user.last_name || "")[0] || ""); } else { let name = user.first_name || user.name || user.title || "FN"; name = name.toUpperCase(); const nameWords = name.split(" ").filter(Boolean); if (nameWords.length > 1) { nameletters = runes(nameWords[0])[0] + runes(nameWords[nameWords.length - 1])[0]; } else if (nameWords.length === 1) { nameletters = runes(nameWords[0])[0]; } else { nameletters = "FN"; } } const avatarColorArray = [ ["#FF885E", "#FF516A"], ["#FFCD6A", "#FFA85C"], ["#E0A2F3", "#D669ED"], ["#A0DE7E", "#54CB68"], ["#53EDD6", "#28C9B7"], ["#72D5FD", "#2A9EF1"], ["#FFA8A8", "#FF719A"], ]; const nameIndex = user.id ? Math.abs(user.id) % 7 : Math.abs(user.name?.charCodeAt(0) || 1) % 7; const avatarColor = avatarColorArray[nameIndex]; const avatarBuffer = await this.avatarImageletters( nameletters, avatarColor ); avatarImage = await loadImage(avatarBuffer); } if (avatarImage) { avatarCache.set(cacheKey, avatarImage); } return avatarImage; } async downloadMediaImage(media) { if (!media || (!media.path && !media.buffer)) { console.log( "Media tidak memiliki sumber (path/buffer), tidak dapat diunduh." ); return null; } try { let imageBuffer; if (media.buffer) { imageBuffer = media.buffer; } else { const requestedPath = path.resolve(media.path); if (!requestedPath.startsWith(ALLOWED_MEDIA_DIRECTORY)) { console.error( `Akses path ditolak (Path Traversal attempt): ${media.path}` ); throw new Error("Invalid path specified."); } imageBuffer = await fs.promises.readFile(requestedPath); } return loadImage(imageBuffer); } catch (e) { console.error(`Gagal memuat media dari sumber lokal.`, e); return null; } } hexToRgb(hex) { return _hexToRgb(hex); } colorLuminance(hex, lum) { return _colorLuminance(hex, lum); } normalizeColor(color) { return _normalizeColor(color); } lightOrDark(color) { let r, g, b; if (color.match(/^rgb/)) { color = color.match( /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/ ); r = color[1]; g = color[2]; b = color[3]; } else { color = +( "0x" + color.slice(1).replace(color.length < 5 && /./g, "$&$&") ); r = color >> 16; g = (color >> 8) & 255; b = color & 255; } const hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)); return hsp > 127.5 ? "light" : "dark"; } async drawMultilineText( text, entities, fontSize, fontColor, textX, textY, maxWidth, maxHeight, emojiBrand = "apple" ) { if (!text || typeof text !== "string") return createCanvas(1, 1); if (maxWidth > 10000) maxWidth = 10000; if (maxHeight > 10000) maxHeight = 10000; const allEmojiImages = await emojiImageByBrandPromise; const emojiImageJson = allEmojiImages[emojiBrand] || {}; const fallbackEmojiImageJson = allEmojiImages["apple"] || {}; const canvas = createCanvas(maxWidth + fontSize, maxHeight + fontSize); const ctx = canvas.getContext("2d"); text = text.replace(/і/g, "i"); const lineHeight = fontSize * 1.2; const charStyles = new Array(runes(text).length).fill(null).map(() => []); if (entities && typeof entities === "object" && Array.isArray(entities)) { for (const entity of entities) { const style = []; if (["pre", "code", "pre_code", "monospace"].includes(entity.type)) style.push("monospace"); else if ( [ "mention", "text_mention", "hashtag", "email", "phone_number", "bot_command", "url", "text_link", ].includes(entity.type) ) style.push("mention"); else style.push(entity.type); for ( let i = entity.offset; i < Math.min(entity.offset + entity.length, charStyles.length); i++ ) { if (charStyles[i]) charStyles[i].push(...style); } } } else if (typeof entities === "string") { for (let i = 0; i < charStyles.length; i++) charStyles[i].push(entities); } const styledWords = []; const emojiData = emojiDb.searchFromText({ input: text, fixCodePoints: true, }); let currentIndex = 0; const processPlainText = (plainText, startOffset) => { if (!plainText) return; const chars = runes(plainText); let currentWord = ""; let currentStyle = JSON.stringify(charStyles[startOffset] || []); const pushWord = () => { if (currentWord) { styledWords.push({ word: currentWord, style: JSON.parse(currentStyle), }); currentWord = ""; } }; for (let i = 0; i < chars.length; i++) { const char = chars[i]; const charIndexInOriginal = startOffset + i; const newStyle = JSON.stringify(charStyles[charIndexInOriginal] || []); if (newStyle !== currentStyle || /<br>|\n|\r|\s/.test(char)) { pushWord(); currentStyle = newStyle; } if (/<br>|\n|\r/.test(char)) { styledWords.push({ word: "\n", style: [] }); } else if (/\s/.test(char)) { styledWords.push({ word: " ", style: [] }); } else { currentWord += char; } } pushWord(); }; emojiData.forEach((emojiInfo) => { if (emojiInfo.offset > currentIndex) { processPlainText( text.substring(currentIndex, emojiInfo.offset), currentIndex ); } styledWords.push({ word: emojiInfo.emoji, style: charStyles[emojiInfo.offset] || [], emoji: { code: emojiInfo.found }, }); currentIndex = emojiInfo.offset + emojiInfo.length; }); if (currentIndex < text.length) { processPlainText(text.substring(currentIndex), currentIndex); } let lineX = textX; let lineY = textY; let textWidth = 0; for (let index = 0; index < styledWords.length; index++) { const styledWord = styledWords[index]; let emojiImage; if (styledWord.emoji) { const emojiImageBase = emojiImageJson[styledWord.emoji.code]; if (emojiImageBase) { emojiImage = await loadImage(Buffer.from(emojiImageBase, "base64")); } else if (fallbackEmojiImageJson[styledWord.emoji.code]) { emojiImage = await loadImage( Buffer.from(fallbackEmojiImageJson[styledWord.emoji.code], "base64") ); } } let fontType = ""; let fontName = "Noto Sans"; let fillStyle = fontColor; if (styledWord.style.includes("bold")) fontType += "bold "; if (styledWord.style.includes("italic")) fontType += "italic "; if (styledWord.style.includes("monospace")) fontName = "NotoSansMono"; if ( styledWord.style.includes("mention") && styledWord.style.includes("monospace") ) { fillStyle = "#005740"; } else if (styledWord.style.includes("mention")) { fillStyle = "#007AFF"; } else if (styledWord.style.includes("monospace")) { fillStyle = "#008069"; } else { fillStyle = fontColor; } ctx.font = `${fontType}${fontSize}px "${fontName}"`; ctx.fillStyle = fillStyle; const isNewline = styledWord.word.match(/\n|\r/); const wordWidth = styledWord.emoji ? fontSize : ctx.measureText(styledWord.word).width; if (isNewline) { if (textWidth < lineX) textWidth = lineX; lineX = textX; lineY += lineHeight; continue; } else if (!styledWord.emoji && wordWidth > maxWidth) { for (let ci = 0; ci < styledWord.word.length; ci++) { const c = styledWord.word[ci]; const charWidth = ctx.measureText(c).width; if (lineX + charWidth > maxWidth) { if (textWidth < lineX) textWidth = lineX; lineX = textX; lineY += lineHeight; } ctx.fillText(c, lineX, lineY); lineX += charWidth; } continue; } if (lineX + wordWidth > maxWidth && styledWord.word !== " ") { if (textWidth < lineX) textWidth = lineX; lineX = textX; lineY += lineHeight; } if (lineY > maxHeight) break; if (emojiImage) { const emojiYOffset = fontSize * 0.85; ctx.drawImage( emojiImage, lineX, lineY - emojiYOffset, fontSize, fontSize ); } else if (styledWord.word !== " ") { ctx.fillText(styledWord.word, lineX, lineY); } lineX += styledWord.emoji ? fontSize : ctx.measureText(styledWord.word).width; if (textWidth < lineX) { textWidth = lineX; } } const finalHeight = lineY + lineHeight; const canvasResize = createCanvas( Math.ceil(textWidth), Math.ceil(finalHeight) ); const canvasResizeCtx = canvasResize.getContext("2d"); canvasResizeCtx.drawImage(canvas, 0, 0); return canvasResize; } async drawTruncatedText( text, entities, fontSize, fontColor, maxWidth, emojiBrand = "apple", truncationRatio = 0.95, truncateMaxWidth = null ) { if (!text || typeof text !== "string") return createCanvas(1, 1); const allEmojiImages = await emojiImageByBrandPromise; const emojiImageJson = allEmojiImages[emojiBrand] || {}; const fallbackEmojiImageJson = allEmojiImages["apple"] || {}; const canvas = createCanvas(maxWidth, fontSize * 1.7); const ctx = canvas.getContext("2d"); const isLongUnbreakableUrl = () => { const isUrl = entities?.some(e => ['url', 'text_link'].includes(e.type)); const noSpaces = !text.includes(' '); return isUrl && noSpaces && runes(text).length > 30; }; const charStyles = new Array(runes(text).length).fill(null).map(() => []); if (entities && Array.isArray(entities)) { for (const entity of entities) { const style = []; if (["pre", "code", "pre_code", "monospace"].includes(entity.type)) { style.push("monospace"); } else if (["mention", "text_mention", "hashtag", "email", "phone_number", "bot_command", "url", "text_link"].includes(entity.type)) { style.push("mention"); } else { style.push(entity.type); } for (let i = entity.offset; i < Math.min(entity.offset + entity.length, charStyles.length); i++) { if (charStyles[i]) charStyles[i].push(...style); } } } const styledWords = []; const emojiData = emojiDb.searchFromText({ input: text, fixCodePoints: true }); let currentIndex = 0; const processPlainText = (plainText, startOffset) => { if (!plainText) return; const chars = runes(plainText); let currentWord = ""; let currentStyle = JSON.stringify(charStyles[startOffset] || []); const pushWord = () => { if (currentWord) { styledWords.push({ word: currentWord, style: JSON.parse(currentStyle), }); currentWord = ""; } }; for (let i = 0; i < chars.length; i++) { const char = chars[i]; const charIndexInOriginal = startOffset + i; const newStyle = JSON.stringify(charStyles[charIndexInOriginal] || []); if (newStyle !== currentStyle || /\s/.test(char)) { pushWord(); currentStyle = newStyle; } if (/\s/.test(char)) { styledWords.push({ word: " ", style: [] }); } else { currentWord += char; } } pushWord(); }; emojiData.forEach((emojiInfo) => { if (emojiInfo.offset > currentIndex) { processPlainText(text.substring(currentIndex, emojiInfo.offset), currentIndex); } styledWords.push({ word: emojiInfo.emoji, style: charStyles[emojiInfo.offset] || [], emoji: { code: emojiInfo.found }, }); currentIndex = emojiInfo.offset + emojiInfo.length; }); if (currentIndex < text.length) { processPlainText(text.substring(currentIndex), currentIndex); } ctx.font = `${fontSize}px "Noto Sans"`; const ellipsisWidth = ctx.measureText("…").width; const areaTruncate = truncateMaxWidth ? truncateMaxWidth * truncationRatio : maxWidth * truncationRatio; let drawX = 0; let truncated = false; const visibleWords = []; for (const styledWord of styledWords) { let wordWidth; let fontType = ""; let fontName = "Noto Sans"; let fillStyle = fontColor; if (styledWord.style.includes("bold")) fontType += "bold "; if (styledWord.style.includes("italic")) fontType += "italic "; if (styledWord.style.includes("monospace")) fontName = "NotoSansMono"; if (styledWord.style.includes("mention")) fillStyle = "#007AFF"; ctx.font = `${fontType}${fontSize}px "${fontName}"`; ctx.fillStyle = fillStyle; if (styledWord.emoji) { wordWidth = fontSize; } else { wordWidth = ctx.measureText(styledWord.word).width; if (wordWidth > areaTruncate && styledWord.style.includes("monospace")) { const chars = runes(styledWord.word); let charCount = 0; for (const char of chars) { const charWidth = ctx.measureText(char).width; if (drawX + charWidth + ellipsisWidth > areaTruncate) { truncated = true; break; } visibleWords.push({ word: char, style: styledWord.style, emoji: styledWord.emoji }); drawX += charWidth; charCount++; if (charCount > 100) break; } continue; } } if (drawX + wordWidth + ellipsisWidth > areaTruncate && styledWord.word !== " ") { if (visibleWords.length > 0) { truncated = true; } break; } visibleWords.push(styledWord); drawX += wordWidth; } if (visibleWords.length === 0 && isLongUnbreakableUrl()) { const firstFewChars = runes(text).slice(0, 10).join(''); visibleWords.push({ word: firstFewChars, style: ["mention"] }); truncated = true; } drawX = 0; for (const styledWord of visibleWords) { let fontType = ""; let fontName = "Noto Sans"; let fillStyle = fontColor; if (styledWord.style.includes("bold")) fontType += "bold "; if (styledWord.style.includes("italic")) fontType += "italic "; if (styledWord.style.includes("monospace")) { fontName = "NotoSansMono"; fillStyle = "#008069"; } if (styledWord.style.includes("mention")) { fillStyle = "#007AFF"; } ctx.font = `${fontType}${fontSize}px "${fontName}"`; ctx.fillStyle = fillStyle; if (styledWord.emoji) { const emojiImageBase = emojiImageJson[styledWord.emoji.code] || fallbackEmojiImageJson[styledWord.emoji.code]; if (emojiImageBase) { const emojiImage = await loadImage(Buffer.from(emojiImageBase, "base64")); const emojiYOffset = fontSize * 0.85; ctx.drawImage(emojiImage, drawX, fontSize - emojiYOffset, fontSize, fontSize); } drawX += fontSize; } else { ctx.fillText(styledWord.word, drawX, fontSize); drawX += ctx.measureText(styledWord.word).width; } } if (truncated) { ctx.fillStyle = fontColor; ctx.font = `${fontSize}px "Noto Sans"`; ctx.fillText("…", drawX, fontSize); } return canvas; } drawRoundRect(color, w, h, r) { const x = 0; const y = 0; const canvas = createCanvas(w, h); const canvasCtx = canvas.getContext("2d"); canvasCtx.fillStyle = color; if (w < 2 * r) r = w / 2; if (h < 2 * r) r = h / 2; canvasCtx.beginPath(); canvasCtx.moveTo(x + r, y); canvasCtx.arcTo(x + w, y, x + w, y + h, r); canvasCtx.arcTo(x + w, y + h, x, y + h, r); canvasCtx.arcTo(x, y + h, x, y, r); canvasCtx.arcTo(x, y, x + w, y, r); canvasCtx.closePath(); canvasCtx.fill(); return canvas; } drawGradientRoundRect(colorOne, colorTwo, w, h, r) { const x = 0; const y = 0; const canvas = createCanvas(w, h); const canvasCtx = canvas.getContext("2d"); const gradient = canvasCtx.createLinearGradient(0, 0, w, h); gradient.addColorStop(0, colorOne); gradient.addColorStop(1, colorTwo); canvasCtx.fillStyle = gradient; if (w < 2 * r) r = w / 2; if (h < 2 * r) r = h / 2; canvasCtx.beginPath(); canvasCtx.moveTo(x + r, y); canvasCtx.arcTo(x + w, y, x + w, y + h, r); canvasCtx.arcTo(x + w, y + h, x, y + h, r); canvasCtx.arcTo(x, y + h, x, y, r); canvasCtx.arcTo(x, y, x + w, y, r); canvasCtx.closePath(); canvasCtx.fill(); return canvas; } roundImage(image, r) { const w = image.width; const h = image.height; const canvas = createCanvas(w, h); const canvasCtx = canvas.getContext("2d"); const x = 0; const y = 0; if (w < 2 * r) r = w / 2; if (h < 2 * r) r = h / 2; canvasCtx.beginPath(); canvasCtx.moveTo(x + r, y); canvasCtx.arcTo(x + w, y, x + w, y + h, r); canvasCtx.arcTo(x + w, y + h, x, y + h, r); canvasCtx.arcTo(x, y + h, x, y, r); canvasCtx.arcTo(x, y, x + w, y, r); canvasCtx.save(); canvasCtx.clip(); canvasCtx.closePath(); canvasCtx.drawImage(image, x, y); canvasCtx.restore(); return canvas; } drawReplyLine(lineWidth, height, color) { const canvas = createCanvas(20, height); const context = canvas.getContext("2d"); context.beginPath(); context.moveTo(10, 0); context.lineTo(10, height); context.lineWidth = lineWidth; context.strokeStyle = color; context.stroke(); context.closePath(); return canvas; } trimNameOrNumber(text, maxWords = 2) { const maxLength = 26; const words = text.split(" "); if (words.length > maxWords) { text = words.slice(0, maxWords).join(" "); } else if (text.length > maxLength) { text = text.slice(0, maxLength); } return text; } formatPhoneNumber(text) { text = text.replace(/\D/g, ''); if (text.startsWith('62')) { return `+62 ${text.slice(2, 5)}-${text.slice(5, 9)}-${text.slice(9)}`; } return text.replace(/^(\d{1,4})(\d{1,4})(\d{1,4})(\d{1,4})$/, '+$1$2$3$4'); }; async drawAvatar(user) { const avatarImage = await this.downloadAvatarImage(user); if (avatarImage) { const avatarSize = avatarImage.naturalHeight || avatarImage.height; const canvas = createCanvas(avatarSize, avatarSize); const canvasCtx = canvas.getContext("2d"); const avatarX = 0; const avatarY = 0; canvasCtx.save(); canvasCtx.beginPath(); canvasCtx.arc( avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2, true ); canvasCtx.clip(); canvasCtx.closePath(); canvasCtx.drawImage( avatarImage, avatarX, avatarY, avatarSize, avatarSize ); canvasCtx.restore(); return canvas; } return null; } async drawQuote( scale, backgroundColorOne, backgroundColorTwo, avatar, replyName, replyNameColor, finalReplyTextCanvas, replyNumber, name, number, text, media, mediaType, finalContentWidth, replyMedia, replyMediaType, replyThumbnailSize, fromTime, emojiBrand = "apple", gap ) { const avatarPosX = 0; const avatarPosY = 5 * scale; const avatarSize = 50 * scale; const indent = 14 * scale; const blockPosX = avatarSize + 10 * scale; const width = blockPosX + finalContentWidth; const quotedThumbW = replyMedia ? Math.min(finalContentWidth * 0.25, replyMedia.width) : 0; const quotedThumbH = replyMedia ? replyMedia.height * (quotedThumbW / replyMedia.width) : 0; const replyNameHeight = replyName?.height || 0; const replyNumberHeight = replyNumber?.height || 0; const quotedTextHeight = finalReplyTextCanvas?.height || 0; const replyNameBarHeight = Math.max(replyNameHeight, replyNumberHeight, 1); const replyQuotedHeight = Math.max(replyNameBarHeight + quotedTextHeight, quotedThumbH) + indent / 2 - 25 * scale; const replyBubbleWidth = finalContentWidth - indent * 2; const namePosX = blockPosX + indent; const textPosX = blockPosX + indent; const nameHeight = name?.height || 0; const numberHeight = number?.height || 0; const nameBarHeight = Math.max(nameHeight, numberHeight, 1); let namePosY = indent; let currentY = namePosY + nameBarHeight - 25 * scale; let rectHeight = currentY; let replyBubblePosX = textPosX; let replyBubblePosY = currentY; if (replyName && (finalReplyTextCanvas || replyMedia)) { currentY += replyQuotedHeight; rectHeight = currentY; } let mediaPosX, mediaPosY, mediaWidth, mediaHeight; if (media) { mediaWidth = finalContentWidth - indent * 2; mediaHeight = media.height * (mediaWidth / media.width); mediaPosX = textPosX; mediaPosY = currentY; currentY += mediaHeight + indent; rectHeight = currentY; } let textPosY = currentY; if (text) { currentY += text.height; rectHeight = currentY; } const height = Math.max(rectHeight + indent, avatarSize + indent * 2); const canvas = createCanvas(width, height); const ctx = canvas.getContext("2d"); const rectWidth = width - blockPosX; const rect = backgroundColorOne === backgroundColorTwo ? this.drawRoundRect(backgroundColorOne, rectWidth, height, 25 * scale) : this.drawGradientRoundRect( backgroundColorOne, backgroundColorTwo, rectWidth, height, 25 * scale ); ctx.drawImage(rect, blockPosX, 0); if (avatar) { ctx.drawImage(avatar, avatarPosX, avatarPosY, avatarSize, avatarSize); } if (name) { ctx.drawImage( name, namePosX, namePosY + (nameBarHeight - name.height) / 2 ); } if (number) { let nomorX = blockPosX + indent + (finalContentWidth - number.width - indent * 2); ctx.drawImage( number, nomorX, namePosY + (nameBarHeight - name.height) / 2 ); } if (replyName && (finalReplyTextCanvas || replyMedia)) { const replyBg = this.drawRoundRect( replyNameColor, replyBubbleWidth, replyQuotedHeight, 10 * scale ); ctx.drawImage(replyBg, replyBubblePosX, replyBubblePosY); ctx.save(); ctx.beginPath(); ctx.moveTo(replyBubblePosX + 10 * scale, replyBubblePosY); ctx.arcTo( replyBubblePosX + replyBubbleWidth, replyBubblePosY, replyBubblePosX + replyBubbleWidth, replyBubblePosY + replyQuotedHeight, 10 * scale ); ctx.arcTo( replyBubblePosX + replyBubbleWidth, replyBubblePosY + replyQuotedHeight, replyBubblePosX, replyBubblePosY + replyQuotedHeight, 10 * scale ); ctx.arcTo( replyBubblePosX, replyBubblePosY + replyQuotedHeight, replyBubblePosX, replyBubblePosY, 10 * scale ); ctx.arcTo( replyBubblePosX, replyBubblePosY, replyBubblePosX + replyBubbleWidth, replyBubblePosY, 10 * scale ); ctx.closePath(); ctx.clip(); ctx.fillStyle = _colorLuminance(backgroundColorOne, 0.09); ctx.fillRect( replyBubblePosX + 7 * scale, replyBubblePosY, replyBubbleWidth * scale, replyQuotedHeight * scale ); ctx.restore(); let nameEndsAt = replyBubblePosX + indent; if (replyName) { const nameX = replyBubblePosX + indent; const nameY = replyBubblePosY + (replyNameBarHeight - replyName.height) / 2 + (5 * scale); ctx.drawImage(replyName, nameX, nameY); nameEndsAt = nameX + replyName.width; } let mediaStartsAt = replyBubblePosX + replyBubbleWidth - indent; if (replyMedia) { const mediaX = replyBubblePosX + replyBubbleWidth - quotedThumbW - indent; const mediaY = replyBubblePosY + (replyQuotedHeight - quotedThumbH) / 2; ctx.drawImage(this.roundImage(replyMedia, 7 * scale), mediaX, mediaY, quotedThumbW, quotedThumbH); mediaStartsAt = mediaX; } if (replyNumber) { const leftBoundary = nameEndsAt + gap; const rightBoundary = mediaStartsAt - indent * 0.80; const numberX = rightBoundary - replyNumber.width; if (numberX > leftBoundary) { const numberY = replyBubblePosY + (replyNameBarHeight - replyName.height) / 2 + (5 * scale); ctx.drawImage(replyNumber, numberX, numberY); } } if (finalReplyTextCanvas) { ctx.drawImage( finalReplyTextCanvas, replyBubblePosX + indent, replyBubblePosY + replyNameBarHeight - (15 * scale) ); } } if (media) { ctx.drawImage( this.roundImage(media, 8 * scale), mediaPosX, mediaPosY + 15, mediaWidth, mediaHeight ); } if (text) { ctx.drawImage(text, textPosX, textPosY + 10); } if (fromTime) { const timeFontSize = 15 * scale; ctx.font = `bold ${timeFontSize}px "Noto Sans"`; ctx.fillStyle = "#888"; ctx.textAlign = "right"; ctx.textBaseline = "bottom"; ctx.fillText( fromTime, canvas.width - indent * 1.2, canvas.height - indent * 0.7 ); } return canvas; } async generate( backgroundColorOne, backgroundColorTwo, message, width = 512, height = 512, scale = 2, emojiBrand = "apple" ) { if (!scale) scale = 2; if (scale > 20) scale = 20; width = width || 512; height = height || 512; width *= scale; height *= scale; const backStyle = this.lightOrDark(backgroundColorOne); const gap = 15 * scale; const nameColorLight = [ "#FC5C51", "#FA790F", "#895DD5", "#0FB297", "#D54FAF", "#0FC9D6", "#3CA5EC", ]; const nameColorDark = [ "#FF8E86", "#FFA357", "#B18FFF", "#4DD6BF", "#FF7FD5", "#45E8D1", "#7AC9FF", ]; let nameIndex = 1; if (message.from && message.from.id) { nameIndex = Math.abs(message.from.id) % 7; } const nameColorArray = backStyle === "light" ? nameColorLight : nameColorDark; let nameColor = nameColorArray[nameIndex]; const colorContrast = new ColorContrast(); const contrast = colorContrast.getContrastRatio( this.colorLuminance(backgroundColorOne, 0.55), nameColor ); if (contrast > 90 || contrast < 30) { nameColor = colorContrast.adjustContrast( this.colorLuminance(backgroundColorTwo, 0.55), nameColor ); } const nameSize = 28 * scale; let textColor = backStyle === "light" ? "#000" : "#fff"; const indent = 14 * scale; let nameText = message.from.name || `${message.from.first_name || ""} ${message.from.last_name || ""}`.trim(); if (!nameText) nameText = "Yanto Baut"; const nameCanvas = await this.drawMultilineText( this.trimNameOrNumber(nameText, 2), [{ type: "bold", offset: 0, length: runes(nameText).length }], nameSize, nameColor, 0, nameSize, width, nameSize, emojiBrand ); let numberCanvas = null; if (message.from && message.from.number) { const messageNumber = this.formatPhoneNumber(message.from.number) numberCanvas = await this.drawMultilineText( this.trimNameOrNumber(messageNumber, 2), [], Math.floor(nameSize * 0.6), nameColor, 0, nameSize, width, nameSize, emojiBrand ); } let textCanvas; if (message.text) { textCanvas = await this.drawMultilineText( message.text, message.entities, 24 * scale, textColor, 0, 24 * scale, width, height, emojiBrand ); } let avatarCanvas; if (message.avatar && message.from) { avatarCanvas = await this.drawAvatar(message.from); } let mediaCanvas; if (message.media) { mediaCanvas = await this.downloadMediaImage(message.media); } const mainNameBarWidth = (nameCanvas?.width || 0) + (numberCanvas?.width || 0) + indent * 1.5; const mainTextWidth = textCanvas?.width || 0; const mainMediaWidth = mediaCanvas ? width - indent * 4 : 0; const mainContentRequiredWidth = Math.max( mainNameBarWidth, mainTextWidth, mainMediaWidth ); let replyContentRequiredWidth = 0; let replyNameCanvas, replyNumberCanvas, replyTextCanvas_forMeasure, replyMedia, replyMediaType, replyNameColor; if (message.replyMessage && message.replyMessage.name && message.replyMessage.text) { try { const chatId = message.replyMessage.chatId || 0; const replyNameIndex = Math.abs(chatId) % 7; replyNameColor = nameColorArray[replyNameIndex]; const replyNameFontSize = 27 * scale; replyNameCanvas = await this.drawMultilineText( this.trimNameOrNumber(message.replyMessage.name, 2), "bold", replyNameFontSize, replyNameColor, 0, replyNameFontSize, width, replyNameFontSize, emojiBrand ); if (message.replyMessage.number) { const replyMessageNumber = this.formatPhoneNumber(message.replyMessage.number) replyNumberCanvas = await this.drawMultilineText( this.trimNameOrNumber(replyMessageNumber, 2), [], Math.floor(replyNameFontSize * 0.6), replyNameColor, 0, replyNameFontSize, width, replyNameFontSize, emojiBrand ); } if (message.replyMessage.text) { replyTextCanvas_forMeasure = await this.drawMultilineText( message.replyMessage.text, message.replyMessage.entities, 22 * scale, textColor, 0, 22 * scale, width, height, emojiBrand ); } if (message.replyMessage.media) { let rawReplyMedia = await this.downloadMediaImage(message.replyMessage.media); replyMediaType = message.replyMessage.mediaType; if (rawReplyMedia) { const targetSize = 60 * scale; const tempCanvas = createCanvas( rawReplyMedia.width, rawReplyMedia.height ); const tempCtx = tempCanvas.getContext("2d"); tempCtx.drawImage(rawReplyMedia, 0, 0); const canvasBuffer = tempCanvas.toBuffer("image/png"); const resizedBuffer = await sharp(canvasBuffer) .resize(targetSize, targetSize, { fit: "fill", position: "center", }) .png() .toBuffer(); replyMedia = await loadImage(resizedBuffer); } } const replyNameBarWidth = (replyNameCanvas?.width || 0) + (replyNumberCanvas?.width || 0) + indent; const replyTextWidth = replyTextCanvas_forMeasure?.width || 0; replyContentRequiredWidth = Math.max(replyNameBarWidth, replyTextWidth); } catch (error) { console.error("Error generating reply message:", error); [ replyNameCanvas, replyNumberCanvas, replyMedia, replyTextCanvas_forMeasure, replyNameColor, ] = Array(5).fill(null); } } const finalContentWidth = Math.max(mainContentRequiredWidth, replyContentRequiredWidth) + indent * 2; if (mediaCanvas && mediaCanvas.width > finalContentWidth - indent * 2) { const tempCanvas = createCanvas(mediaCanvas.width, mediaCanvas.height); const tempCtx = tempCanvas.getContext('2d'); tempCtx.drawImage(mediaCanvas, 0, 0); const buffer = tempCanvas.toBuffer("image/png"); const resizedBuffer = await sharp(buffer) .resize({ width: finalContentWidth - indent * 2 }) .png() .toBuffer(); mediaCanvas = await loadImage(resizedBuffer); } let quotedThumbW = 0; let quotedThumbH = 0; if (replyMedia) { quotedThumbW = Math.min(95 * scale, replyMedia.width); quotedThumbH = replyMedia.height * (quotedThumbW / replyMedia.width); } let finalReplyTextCanvas; if (replyTextCanvas_forMeasure) { const quotedTextMaxWidth = replyMedia ? finalContentWidth - indent * 3 - quotedThumbW : finalContentWidth - indent * 3; finalReplyTextCanvas = await this.drawTruncatedText( message.replyMessage.text, message.replyMessage.entities, 22 * scale, textColor, quotedTextMaxWidth, emojiBrand ); } let finalTextCanvas; if (textCanvas) { const mainBubbleWidth = finalContentWidth - indent * 2; finalTextCanvas = await this.drawMultilineText( message.text, message.entities, 24 * scale, textColor, 0, 24 * scale, mainBubbleWidth, height, emojiBrand ); } let fromTime = message.from?.time || null; const quote = await this.drawQuote( scale, backgroundColorOne, backgroundColorTwo, avatarCanvas, replyNameCanvas, replyNameColor, finalReplyTextCanvas, replyNumberCanvas, nameCanvas, numberCanvas, finalTextCanvas, mediaCanvas, message.mediaType, finalContentWidth, replyMedia, replyMediaType, quotedThumbH, fromTime, emojiBrand, gap ); return quote; } } const imageAlpha = (image, alpha) => { const canvas = createCanvas(image.width, image.height); const canvasCtx = canvas.getContext("2d"); canvasCtx.globalAlpha = alpha; canvasCtx.drawImage(image, 0, 0); return canvas; }; module.exports = async (parm) => { await fontsLoadedPromise; if (!parm) { return { error: "query_empty", }; } if (!parm.messages || parm.messages.length < 1) { return { error: "messages_empty", }; } const quoteGenerate = new QuoteGenerate(); const quoteImages = []; let backgroundColor = parm.backgroundColor || "//#292232"; let backgroundColorOne, backgroundColorTwo; const backgroundColorSplit = backgroundColor.split("/"); if (backgroundColorSplit && backgroundColorSplit.length > 1 && backgroundColorSplit[0] !== "") { backgroundColorOne = _normalizeColor(backgroundColorSplit[0]); backgroundColorTwo = _normalizeColor(backgroundColorSplit[1]); } else if (backgroundColor.startsWith("//")) { backgroundColor = _normalizeColor(backgroundColor.replace("//", "")); backgroundColorOne = _colorLuminance(backgroundColor, 0.35); backgroundColorTwo = _colorLuminance(backgroundColor, -0.15); } else { backgroundColor = _normalizeColor(backgroundColor); backgroundColorOne = backgroundColor; backgroundColorTwo = backgroundColor; } for (const key in parm.messages) { const message = parm.messages[key]; if (message) { if (!message.from) message.from = { id: 0, }; if (message.from.photo) { message.avatar = true; } if ( !message.from.name && (message.from.first_name || message.from.last_name) ) { message.from.name = [message.from.first_name, message.from.last_name] .filter(Boolean) .join(" "); } if (message.replyMessage) { if (!message.replyMessage.chatId) message.replyMessage.chatId = message.from?.id || 0; if (!message.replyMessage.entities) message.replyMessage.entities = []; if (!message.replyMessage.from) { message.replyMessage.from = { name: message.replyMessage.name, photo: {}, }; } else if (!message.replyMessage.from.photo) { message.replyMessage.from.photo = {}; } } const canvasQuote = await quoteGenerate.generate( backgroundColorOne, backgroundColorTwo, message, parm.width, parm.height, parseFloat(parm.scale) || 2, parm.emojiBrand || "apple" ); quoteImages.push(canvasQuote); } } if (quoteImages.length === 0) { return { error: "empty_messages", }; } let canvasQuote; if (quoteImages.length > 1) { let width = 0, height = 0; for (let index = 0; index < quoteImages.length; index++) { if (quoteImages[index].width > width) width = quoteImages[index].width; height += quoteImages[index].height; } const quoteMargin = parm.scale ? 5 * parm.scale : 10; const canvas = createCanvas( width, height + quoteMargin * (quoteImages.length - 1) ); const canvasCtx = canvas.getContext("2d"); let imageY = 0; for (let index = 0; index < quoteImages.length; index++) { canvasCtx.drawImage(quoteImages[index], 0, imageY); imageY += quoteImages[index].height + quoteMargin; } canvasQuote = canvas; } else { canvasQuote = quoteImages[0]; } let quoteImage; let { type } = parm; const scale = parseFloat(parm.scale) || 2; if (!type) { type = "quote"; } if (type === "quote") { const downPadding = 75; const maxWidth = 512; const maxHeight = 512; const imageQuoteSharp = sharp(canvasQuote.toBuffer()); if (canvasQuote.height > canvasQuote.width) imageQuoteSharp.resize({ height: maxHeight, }); else imageQuoteSharp.resize({ width: maxWidth, }); const canvasImage = await loadImage(await imageQuoteSharp.toBuffer()); const canvasPadding = createCanvas( canvasImage.width, canvasImage.height + downPadding ); const canvasPaddingCtx = canvasPadding.getContext("2d"); canvasPaddingCtx.drawImage(canvasImage, 0, 0); const imageSharp = sharp(canvasPadding.toBuffer()); if (canvasPadding.height >= canvasPadding.width) imageSharp.resize({ height: maxHeight, }); else imageSharp.resize({ width: maxWidth, }); quoteImage = await imageSharp.png().toBuffer(); } else if (type === "image") { const heightPadding = 75 * scale; const widthPadding = 95 * scale; const canvasImage = await loadImage(canvasQuote.toBuffer()); const canvasPic = createCanvas( canvasImage.width + widthPadding, canvasImage.height + heightPadding ); const canvasPicCtx = canvasPic.getContext("2d"); const gradient = canvasPicCtx.createRadialGradient( canvasPic.width / 2, canvasPic.height / 2, 0, canvasPic.width / 2, canvasPic.height / 2, canvasPic.width / 2 ); const patternColorOne = _colorLuminance(backgroundColorTwo, 0.15); const patternColorTwo = _colorLuminance(backgroundColorOne, 0.15); gradient.addColorStop(0, patternColorOne); gradient.addColorStop(1, patternColorTwo); canvasPicCtx.fillStyle = gradient; canvasPicCtx.fillRect(0, 0, canvasPic.width, canvasPic.height); try { const canvasPatternImage = await loadImage( path.join(__dirname, "../assets/pattern_02.png") ); const pattern = canvasPicCtx.createPattern( imageAlpha(canvasPatternImage, 0.3), "repeat" ); canvasPicCtx.fillStyle = pattern; canvasPicCtx.fillRect(0, 0, canvasPic.width, canvasPic.height); } catch (e) { console.log("Gagal memuat pattern. Melanjutkan tanpa pattern."); } canvasPicCtx.shadowOffsetX = 8; canvasPicCtx.shadowOffsetY = 8; canvasPicCtx.shadowBlur = 13; canvasPicCtx.shadowColor = "rgba(0, 0, 0, 0.5)"; canvasPicCtx.drawImage(canvasImage, widthPadding / 2, heightPadding / 2); canvasPicCtx.shadowOffsetX = 0; canvasPicCtx.shadowOffsetY = 0; canvasPicCtx.shadowBlur = 0; canvasPicCtx.shadowColor = "rgba(0, 0, 0, 0)"; canvasPicCtx.fillStyle = `rgba(0, 0, 0, 0.3)`; canvasPicCtx.font = `${8 * scale}px "Noto Sans"`; canvasPicCtx.textAlign = "right"; quoteImage = await sharp(canvasPic.toBuffer()) .png({ lossless: true, force: true, }) .toBuffer(); } else if (type === "stories") { const canvasPic = createCanvas(720, 1280); const canvasPicCtx = canvasPic.getContext("2d"); const gradient = canvasPicCtx.createRadialGradient( canvasPic.width / 2, canvasPic.height / 2, 0, canvasPic.width / 2, canvasPic.height / 2, canvasPic.width / 2 ); const patternColorOne = _colorLuminance(backgroundColorTwo, 0.25); const patternColorTwo = _colorLuminance(backgroundColorOne, 0.15); gradient.addColorStop(0, patternColorOne); gradient.addColorStop(1, patternColorTwo); canvasPicCtx.fillStyle = gradient; canvasPicCtx.fillRect(0, 0, canvasPic.width, canvasPic.height); try { const canvasPatternImage = await loadImage( path.join(__dirname, "../assets/pattern_02.png") ); const pattern = canvasPicCtx.createPattern( imageAlpha(canvasPatternImage, 0.3), "repeat" ); canvasPicCtx.fillStyle = pattern; canvasPicCtx.fillRect(0, 0, canvasPic.width, canvasPic.height); } catch (e) { console.log("Gagal memuat pattern. Melanjutkan tanpa pattern."); } canvasPicCtx.shadowOffsetX = 8; canvasPicCtx.shadowOffsetY = 8; canvasPicCtx.shadowBlur = 13; canvasPicCtx.shadowColor = "rgba(0, 0, 0, 0.5)"; let canvasImage = await loadImage(canvasQuote.toBuffer()); const minPadding = 110; if ( canvasImage.width > canvasPic.width - minPadding * 2 || canvasImage.height > canvasPic.height - minPadding * 2 ) { canvasImage = await sharp(canvasQuote.toBuffer()) .resize({ width: canvasPic.width - minPadding * 2, height: canvasPic.height - minPadding * 2, fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0, }, }) .toBuffer(); canvasImage = await loadImage(canvasImage); } const imageX = (canvasPic.width - canvasImage.width) / 2; const imageY = (canvasPic.height - canvasImage.height) / 2; canvasPicCtx.drawImage(canvasImage, imageX, imageY); canvasPicCtx.shadowOffsetX = 0; canvasPicCtx.shadowOffsetY = 0; canvasPicCtx.shadowBlur = 0; canvasPicCtx.fillStyle = `rgba(0, 0, 0, 0.4)`; canvasPicCtx.font = `${16 * scale}px "Noto Sans"`; canvasPicCtx.textAlign = "center"; canvasPicCtx.translate(70, canvasPic.height / 2); canvasPicCtx.rotate(-Math.PI / 2); quoteImage = await sha