Download createImage.js from hansaka1/quotly: direct link, hf CLI and curl.
- Browser
- Download file 31.1 kB
-
https://huggingface.co/spaces/hansaka1/quotly/resolve/main/createImage.js
- Command line
-
hf download hf://spaces/hansaka1/quotly/createImage.js
-
curl -L -o createImage.js https://huggingface.co/spaces/hansaka1/quotly/resolve/main/createImage.js
31.1 kB
| const puppeteer = require('puppeteer'); | |
| // *** RESTORED: createCanvas, registerFont from 'canvas' *** | |
| const { createCanvas, registerFont } = require('canvas'); | |
| const fs = require('fs'); | |
| const fsPromises = require('fs').promises; | |
| const path = require('path'); | |
| const sharp = require('sharp'); | |
| const axios = require('axios'); | |
| require('dotenv').config | |
| // const { text } = require('stream/consumers'); // <-- Removed, this was unused | |
| // --- CONFIGURATION --- | |
| const BOT_TOKEN = process.env.BOT_TOKEN; // IMPORTANT: Replace with your token | |
| // --- FONT DEFINITIONS --- | |
| const fontMap = { | |
| '/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf': 'Noto Sans', | |
| '/usr/share/fonts/truetype/noto/NotoSansSinhala-Regular.ttf': 'Noto Sans Sinhala', | |
| '/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf': 'Noto Color Emoji', | |
| '/usr/share/fonts/truetype/noto/NotoSansSymbols-Regular.ttf': 'Noto Sans Symbols', | |
| '/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf': 'Noto Sans Symbols 2', | |
| '/usr/share/fonts/truetype/noto/NotoSansMath-Regular.ttf': 'Noto Sans Math', | |
| '/usr/share/fonts/truetype/noto/NotoSansMeeteiMayek-Regular.ttf': 'Noto Sans Meetei Mayek' | |
| }; | |
| // --- FONT LOADING FOR NODE-CANVAS (Used by generateNameHtml) --- | |
| console.log('Registering fonts for node-canvas...'); | |
| Object.entries(fontMap).forEach(([fontPath, familyName]) => { | |
| try { | |
| if (fs.existsSync(fontPath)) { | |
| registerFont(fontPath, { family: familyName }); | |
| console.log(`Registered for node-canvas: ${fontPath} as "${familyName}"`); | |
| } else { | |
| console.warn(`⚠️ node-canvas: Font file not found at ${fontPath}`); | |
| } | |
| } catch (e) { | |
| console.warn(`⚠️ node-canvas: Could not register font at ${fontPath}: ${e.message}`); | |
| } | |
| }); | |
| // --- FONT LOADING FOR PUPPETEER (REMOVED) --- | |
| // Font-face rules are no longer injected. Puppeteer will use system-installed fonts. | |
| // This is the CSS font-family stack Puppeteer will now use | |
| // This is the CSS font-family stack Puppeteer will now use | |
| const FONT_STACK = "'Noto Sans', 'Noto Sans Sinhala', 'Noto Sans Meetei Mayek', 'Noto Sans Math', 'Noto Sans Symbols', 'Noto Sans Symbols 2', 'Noto Color Emoji'"; | |
| // This stack is for the dummy avatar, which also runs in Puppeteer | |
| const DUMMY_AVATAR_FONT_STACK = "'Noto Color Emoji', 'Noto Sans'"; | |
| // --- HELPER FUNCTIONS --- | |
| function getTelegramDarkThemeColor(id) { const map = new Map([[0, '#FF516A'], [1, '#FF9442'], [2, '#C66FFF'], [3, '#50D892'], [4, '#64D4F5'], [5, '#5095ED'], [6, '#FF66A6'], [7, '#FF8280'], [8, '#EDD64E'], [9, '#C66FFF']]); return map.get(id) || '#00ffff'; } | |
| // --- NEW PUPPETEER-BASED AVATAR FUNCTION --- | |
| async function createDummyAvatarBuffer(f, l, c, scale = 1) { | |
| const avatarSize = 140 * scale; | |
| // 1. Determine Initial Text (Unicode-safe) | |
| let initialText = ''; | |
| const firstChar = f ? (Array.from(f)[0] || '') : ''; | |
| const isFirstCharEmoji = /\p{Emoji}/u.test(firstChar); | |
| if (isFirstCharEmoji) { | |
| initialText = firstChar; | |
| } else { | |
| const firstInitial = firstChar; | |
| const lastInitial = l ? (Array.from(l)[0] || '') : ''; | |
| initialText = (firstInitial + lastInitial).toUpperCase().trim(); | |
| } | |
| if (!initialText) { | |
| initialText = '?'; | |
| } | |
| // 2. Determine Font Size | |
| const graphemeCount = Array.from(initialText).length; | |
| const isSingleEmoji = graphemeCount === 1 && /\p{Emoji}/u.test(initialText); | |
| let fontSize; | |
| let fontWeight = 'bold'; | |
| if (isSingleEmoji) { | |
| fontSize = 72 * scale; | |
| fontWeight = 'normal'; // Emojis look better as normal weight | |
| } else if (graphemeCount === 1) { | |
| fontSize = 64 * scale; | |
| } else { | |
| fontSize = 48 * scale; | |
| } | |
| // 3. Generate HTML for the avatar | |
| const htmlContent = ` | |
| <html><head><style> | |
| /* --- Font-face rules removed --- */ | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| width: ${avatarSize}px; | |
| height: ${avatarSize}px; | |
| font-family: ${DUMMY_AVATAR_FONT_STACK}; /* Use the font stack */ | |
| } | |
| #avatar { | |
| width: 100%; | |
| height: 100%; | |
| background-color: ${c}; | |
| border-radius: 50%; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| color: #FFF; | |
| font-size: ${fontSize}px; | |
| font-weight: ${fontWeight}; | |
| line-height: 1; | |
| text-align: center; | |
| overflow: hidden; /* Just in case */ | |
| } | |
| </style></head> | |
| <body> | |
| <div id="avatar">${escapeHtml(initialText)}</div> | |
| </body></html> | |
| `; | |
| // 4. Launch a *separate, minimal* Puppeteer instance to render it | |
| let browser; | |
| let pngBuffer; | |
| try { | |
| browser = await puppeteer.launch({ headless: true,executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox', '--disable-gpu'] }); | |
| const page = await browser.newPage(); | |
| await page.setViewport({ width: avatarSize, height: avatarSize }); | |
| await page.setContent(htmlContent, { waitUntil: 'domcontentloaded' }); // Reverted from networkidle0 | |
| const element = await page.$('#avatar'); | |
| pngBuffer = await element.screenshot({ omitBackground: true }); // Screenshot just the circle | |
| } catch (e) { | |
| console.error("❌ Error creating dummy avatar with Puppeteer:", e.message); | |
| return null; // Fallback | |
| } finally { | |
| if (browser) { | |
| await browser.close(); | |
| } | |
| } | |
| return pngBuffer; | |
| } | |
| // --- HELPER FUNCTION: Fetches and CONVERTS to PNG --- | |
| const EMOJI_STATUS_CACHE_DIR = './emoji_status'; | |
| if (!fs.existsSync(EMOJI_STATUS_CACHE_DIR)) { | |
| fs.mkdirSync(EMOJI_STATUS_CACHE_DIR); | |
| } | |
| const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); | |
| async function getEmojiStatusBuffer(emojiId) { | |
| const cachePath = `${EMOJI_STATUS_CACHE_DIR}/${emojiId}.png`; | |
| // 1. Check local cache | |
| if (fs.existsSync(cachePath)) { | |
| return fs.readFileSync(cachePath); | |
| } | |
| const PROXY_URL = 'https://telegram-proxy.resident.workers.dev'; | |
| const maxRetries = 6; | |
| for (let attempt = 1; attempt <= maxRetries; attempt++) { | |
| try { | |
| // Define common headers to prevent ENOTFOUND/EAI_AGAIN on Hugging Face | |
| const axiosConfig = { | |
| headers: { | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', | |
| 'Accept': 'application/json', | |
| 'Host': 'telegram-proxy.resident.workers.dev' | |
| }, | |
| timeout: 15000 // 15s timeout for slow proxy wake-ups | |
| }; | |
| // 2. Get Metadata via Proxy | |
| const stickerApiUrl = `${PROXY_URL}/bot${BOT_TOKEN}/getCustomEmojiStickers`; | |
| const stickerResponse = await axios.post(stickerApiUrl, | |
| { custom_emoji_ids: [emojiId] }, | |
| axiosConfig | |
| ); | |
| const stickers = stickerResponse.data.result; | |
| if (!stickers || stickers.length === 0) throw new Error("Emoji not found in Telegram."); | |
| const sticker = stickers[0]; | |
| /** | |
| * VIDEO EMOJI LOGIC: | |
| * Since your emoji is 'is_video: true', we MUST use the thumbnail. | |
| * Telegram provides 'thumbnail' or 'thumb'. We prioritize these. | |
| */ | |
| let targetFileId = null; | |
| if (sticker.thumbnail && sticker.thumbnail.file_id) { | |
| targetFileId = sticker.thumbnail.file_id; | |
| } else if (sticker.thumb && sticker.thumb.file_id) { | |
| targetFileId = sticker.thumb.file_id; | |
| } else { | |
| // Fallback to main file_id if it's not a video (Sharp can't read .webm) | |
| if (sticker.is_video || sticker.is_animated) { | |
| throw new Error("No static thumbnail found for animated/video emoji."); | |
| } | |
| targetFileId = sticker.file_id; | |
| } | |
| // 3. Get File Path | |
| const fileApiUrl = `${PROXY_URL}/bot${BOT_TOKEN}/getFile`; | |
| const fileResponse = await axios.post(fileApiUrl, | |
| { file_id: targetFileId }, | |
| axiosConfig | |
| ); | |
| const filePath = fileResponse.data.result.file_path; | |
| // 4. Download Binary Data | |
| const fileUrl = `${PROXY_URL}/file/bot${BOT_TOKEN}/${filePath}`; | |
| const imageResponse = await axios.get(fileUrl, { | |
| ...axiosConfig, | |
| responseType: 'arraybuffer' | |
| }); | |
| // 5. Process with Sharp | |
| // We convert to PNG and ensure it's a standard size for the quote template | |
| const pngBuffer = await sharp(imageResponse.data) | |
| .resize(100, 100, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) | |
| .png() | |
| .toBuffer(); | |
| // 6. Save to cache and return | |
| fs.writeFileSync(cachePath, pngBuffer); | |
| console.log(`✅ Successfully cached emoji: ${emojiId}`); | |
| return pngBuffer; | |
| } catch (error) { | |
| const errorMsg = error.response ? JSON.stringify(error.response.data) : error.message; | |
| console.warn(`[Attempt ${attempt}/${maxRetries}] Emoji Fetch Error: ${errorMsg}`); | |
| if (attempt === maxRetries) { | |
| console.error(`❌ Permanent failure for Emoji ID ${emojiId}. Returning null.`); | |
| return null; | |
| } | |
| // Exponential backoff: 2s, 4s, 6s... | |
| await new Promise(resolve => setTimeout(resolve, 2000 * attempt)); | |
| } | |
| } | |
| return null; | |
| } | |
| function escapeHtml(text) { | |
| if (!text) return ''; | |
| // *** FIX: Corrected regex from /&g to /&/g *** | |
| return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); | |
| } | |
| // *** RESTORED: createTextChunkImageBuffer function *** | |
| function createTextChunkImageBuffer(text, { fontSize = 20, color = '#FFFFFF' }) { | |
| const canvas = createCanvas(1, 1); | |
| const ctx = canvas.getContext('2d'); | |
| // Use the *exact* font stack from node-canvas registration | |
| ctx.font = `bold ${fontSize}px ${FONT_STACK}`; | |
| const metrics = ctx.measureText(text); | |
| const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; | |
| // *** FIX: Keep padding at 0 *** | |
| const padding = 0; | |
| // *** FIX: Ensure width and height are at least 1px *** | |
| const canvasWidth = Math.max(1, metrics.width + 2 * padding); | |
| const canvasHeight = Math.max(1, textHeight + 2 * padding); | |
| const textCanvas = createCanvas(canvasWidth, canvasHeight); | |
| const textCtx = textCanvas.getContext('2d'); | |
| textCtx.font = `bold ${fontSize}px ${FONT_STACK}`; // Use the *exact* font stack | |
| textCtx.fillStyle = color; | |
| textCtx.textBaseline = 'alphabetic'; | |
| // Draw text only if width > 0 to avoid potential issues | |
| if (metrics.width > 0) { | |
| textCtx.fillText(text, padding, metrics.actualBoundingBoxAscent + padding); | |
| } | |
| return textCanvas.toBuffer('image/png'); | |
| } | |
| // *** RESTORED: generateNameHtml function *** | |
| // *** RESTORED: generateNameHtml function *** | |
| function generateNameHtml(text, color, fontSize) { | |
| const emojiRegex = /(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/g; | |
| // Split by emoji AND whitespace | |
| const chunks = text.split(/(\s+|(?:\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]))/).filter(Boolean); | |
| let html = ''; | |
| for (const chunk of chunks) { | |
| if (chunk.match(emojiRegex)) { | |
| // It's an emoji | |
| html += `<span class="name-emoji">${escapeHtml(chunk)}</span>`; | |
| // *** FIX: Check for whitespace chunk *** | |
| } else if (chunk.match(/^\s+$/)) { | |
| // It's whitespace. Render it as a span. | |
| // We use 'style="white-space: pre;"' to ensure it's rendered exactly as-is. | |
| html += `<span class="name-whitespace" style="white-space: pre;">${escapeHtml(chunk)}</span>`; | |
| } else { | |
| // It's a text chunk | |
| const trimmedChunk = chunk.trim(); // We can trim, as spaces are handled separately | |
| if (trimmedChunk) { | |
| const chunkImageBuffer = createTextChunkImageBuffer(trimmedChunk, { fontSize: fontSize, color: color }); | |
| const chunkImageBase64 = `data:image/png;base64,${chunkImageBuffer.toString('base64')}`; | |
| html += `<img class="name-chunk-image" src="${chunkImageBase64}" />`; | |
| } | |
| } | |
| } | |
| return html; | |
| } | |
| function wrapTextSmartly(text, maxWidth, font) { | |
| // This function is no longer used by createImage but is kept for potential other uses | |
| // Note: It relies on node-canvas, which might be removed if not used elsewhere | |
| const { createCanvas } = require('canvas'); // Moved import here | |
| const canvas = createCanvas(1, 1); | |
| const ctx = canvas.getContext('2d'); | |
| ctx.font = font; | |
| const sanitizedText = text.replace(/\u200B/g, ' ').trim(); | |
| if (!sanitizedText) return ''; | |
| const words = sanitizedText.split(/\s+/); | |
| let line = ''; | |
| let result = ''; | |
| for (let n = 0; n < words.length; n++) { | |
| const word = words[n]; | |
| const testLine = (line ? line + ' ' : '') + word; | |
| const metrics = ctx.measureText(testLine); | |
| const testWidth = metrics.width; | |
| if ((testWidth > maxWidth && line) || metrics.width > maxWidth) { | |
| result += line.trim() + '\n'; | |
| line = word + ' '; | |
| } else { | |
| line = testLine + ' '; | |
| } | |
| } | |
| result += line.trim(); | |
| return result; | |
| } | |
| /** | |
| * Highlights links, mentions, and commands by wrapping them in <span> tags, | |
| * and escapes all other HTML characters in the plain text segments. | |
| * Newline characters (\n) are converted to <br/> tags. | |
| * @param {string} wrappedText Text that *has not* been pre-wrapped. CSS will handle wrapping. | |
| * @returns {string} HTML string ready for direct insertion into the DOM. | |
| */ | |
| function highlightTextPatterns(wrappedText) { | |
| // Regex: Matches links (http/www), @mentions, or /commands | |
| const patternRegex = /(https?:\/\/[^\s]+|www\.[^\s]+|@\w+|\/\w+)/g; | |
| // Split the text by the highlighted patterns. This interleaves the patterns and the plain text. | |
| const parts = wrappedText.split(patternRegex).filter(p => p !== undefined && p !== null && p !== ''); | |
| let outputHtml = ''; | |
| const highlightColor = '#6ab8ed'; // Light blue for links/mentions/commands | |
| for (const part of parts) { | |
| // *** FIX: Corrected regex from @w+ to @\w+ *** | |
| if (part.match(/^(https?:\/\/[^\s]+|www\.[^\s]+|@\w+|(\/)\w+)$/)) { | |
| // It's a highlighted match. | |
| // 1. Escape the content in case the link/mention/command itself contains '<' or '&'. | |
| const escapedContent = escapeHtml(part); | |
| // 2. Wrap it in a span with the required style. | |
| outputHtml += `<span style="color: ${highlightColor}; text-decoration: underline;">${escapedContent}</span>`; | |
| } else { | |
| // It's plain text. | |
| // 1. Escape all HTML characters. | |
| const escapedText = escapeHtml(part); | |
| // 2. Convert any *intentional* \n line breaks to <br/>. | |
| // The browser will handle the wrapping line breaks. | |
| outputHtml += escapedText.replace(/\n/g, '<br/>'); | |
| } | |
| } | |
| return outputHtml; | |
| } | |
| // --- MAIN PUPPETEER FUNCTION --- | |
| async function createImage(firstName, lastName, customemojiid, message, nameColorId, inputImageBuffer, replySender, replyMessage,replysendercolor) { | |
| const scale = 4; | |
| const username = `${firstName} ${lastName}`.trim().replace(/\u200B/g, ''); | |
| const nameColor = getTelegramDarkThemeColor(nameColorId); | |
| // --- UNIVERSAL FONT SIZING --- | |
| // All text elements will use this single size. | |
| const UNIVERSAL_FONT_SIZE = 26 * scale; | |
| // Set all font sizes to the universal size | |
| let messageFontSize = UNIVERSAL_FONT_SIZE; | |
| let nameImageFontSize = UNIVERSAL_FONT_SIZE; | |
| let replySenderFontSize = UNIVERSAL_FONT_SIZE; | |
| let replyMessageFontSize = UNIVERSAL_FONT_SIZE; | |
| // Emoji size is relative to the text size | |
| let nameEmojiFontSize = nameImageFontSize; | |
| // Standardized layout values based on the universal font size | |
| let nameLineHeight = 34 * scale; | |
| let nameMarginBottom = 12 * scale; | |
| let messageLineHeight = 1.4; | |
| const replyLineHeight = 1.3; | |
| const replyMarginBottom = 10 * scale; | |
| // --- END UNIVERSAL FONT SIZING --- | |
| // --- Text Highlighting & Max Width --- | |
| const DEFAULT_MESSAGE_MAX_WIDTH = 650 * scale; // Max width for the MESSAGE text to wrap | |
| const BUBBLE_MIN_WIDTH = 250 * scale; // Minimum width for the bubble | |
| const REPLY_MESSAGE_MAX_LENGTH = 50; // Max characters for the reply message | |
| // Pass the *original* message directly to the highlighter. | |
| // CSS will handle all the wrapping. | |
| const highlightedMessageHtml = highlightTextPatterns(message); | |
| // --------------------------------------------- | |
| // *** RESTORED: nameContentHtml generation *** | |
| const nameContentHtml = generateNameHtml(username, nameColor, nameImageFontSize); | |
| // *** RESTORED: replySenderHtml generation *** | |
| let replySenderHtml = ''; | |
| const replySenderColor = getTelegramDarkThemeColor(replysendercolor); // Green color | |
| if (replySender) { | |
| replySenderHtml = generateNameHtml(replySender.replace(/\u200B/g, ''), replySenderColor, replySenderFontSize); | |
| } | |
| // *** ADDED: Slicing logic for replyMessage *** | |
| let processedReplyMessage = replyMessage; | |
| if (replySender && processedReplyMessage && processedReplyMessage.length > REPLY_MESSAGE_MAX_LENGTH) { | |
| // Slice the message but don't add ellipsis, the CSS fade-out handles it | |
| processedReplyMessage = processedReplyMessage.substring(0, REPLY_MESSAGE_MAX_LENGTH); | |
| } | |
| let avatarBuffer = inputImageBuffer ? await sharp(inputImageBuffer).png().toBuffer() : await createDummyAvatarBuffer(firstName, lastName, nameColor, scale); | |
| const avatarBase64 = `data:image/png;base64,${avatarBuffer.toString('base64')}`; | |
| const emojiStatusBuffer = customemojiid ? await getEmojiStatusBuffer(customemojiid) : null; | |
| const emojiStatusBase64 = emojiStatusBuffer ? `data:image/png;base64,${emojiStatusBuffer.toString('base64')}` : null; | |
| const htmlContent = ` | |
| <html><head><style> | |
| /* --- Font-face rules removed --- */ | |
| body { | |
| margin: 0; | |
| padding: ${30 * scale}px; /* Body padding for spacing */ | |
| font-family: ${FONT_STACK}, sans-serif; /* Use the full font stack */ | |
| display: flex; | |
| justify-content: flex-start; /* Align content to start */ | |
| align-items: flex-start; | |
| min-height: 100vh; | |
| background-color: transparent; | |
| } | |
| .container { | |
| display: flex; | |
| align-items: flex-end; | |
| } | |
| .avatar { | |
| width: ${70 * scale}px; | |
| height: ${70 * scale}px; | |
| border-radius: 50%; | |
| margin-right: ${15 * scale}px; | |
| flex-shrink: 0; | |
| object-fit: cover; | |
| } | |
| .bubble { | |
| background-color: #2a2233; | |
| border-radius: ${20 * scale}px ${20 * scale}px ${20 * scale}px 0; | |
| padding: ${18 * scale}px ${25 * scale}px ${12 * scale}px ${25 * scale}px; | |
| position: relative; | |
| min-width: ${BUBBLE_MIN_WIDTH}px; /* Ensure bubble isn't too small */ | |
| box-sizing: border-box; | |
| flex-grow: 1; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: flex-start; | |
| } | |
| .bubble::before { | |
| content: ''; | |
| position: absolute; | |
| bottom: 0; | |
| left: -${20 * scale}px; | |
| width: ${20 * scale}px; | |
| height: ${20 * scale}px; | |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath fill='%232a2233' d='M20 0 V20 H0 C10 20 20 10 20 0 Z'/%3E%3C/svg%3E"); | |
| background-size: contain; | |
| background-repeat: no-repeat; | |
| } | |
| /* FIX: Remove flex, rely on inline-block alignment */ | |
| .name-line { | |
| display: flex; | |
| align-items: center; /* Vertically center all items */ | |
| margin-bottom: ${nameMarginBottom}px; | |
| min-height: ${nameLineHeight}px; | |
| font-size: ${nameEmojiFontSize}px; /* Base size for 'em' unit */ | |
| font-weight: bold; | |
| color: ${nameColor}; | |
| line-height: 1; | |
| white-space: nowrap; | |
| } | |
| /* * This targets all children (img, span) to make | |
| * them align properly as flex items. | |
| */ | |
| .name-line > * { | |
| display: block; /* Make them blocks for flex alignment */ | |
| } | |
| /* * KEY FIX: Set the max-height of the text-image to 1em | |
| * This will scale it down to match the font-size. | |
| */ | |
| .name-line > .name-chunk-image { | |
| max-height: 1em; /* 1em = ${nameEmojiFontSize}px */ | |
| width: auto; /* Let width scale with aspect ratio */ | |
| } | |
| /* FIX: Use inline-block and vertical-align. Remove forced height. */ | |
| .name-chunk-image, .name-emoji { | |
| /* height: 1em; REMOVED */ | |
| display: inline-block; | |
| vertical-align: middle; | |
| } | |
| .name-emoji { | |
| /* No specific rules needed */ | |
| } | |
| /* FIX: Add this new rule for the whitespace span */ | |
| .name-whitespace { | |
| display: inline-block; | |
| vertical-align: middle; | |
| } | |
| /* This targets the <img> tags generated by generateNameHtml for the name */ | |
| .name-line .name-chunk-image { | |
| /* height: ${nameImageFontSize}px !important; REMOVED -- THIS FIXES THE SIZE */ | |
| } | |
| .emoji-status { | |
| width: ${nameEmojiFontSize * 1.5}px; | |
| height: ${nameEmojiFontSize * 1.5}px; | |
| margin-left: ${8 * scale}px; /* Keep margin for status emoji */ | |
| vertical-align: middle; | |
| border-radius:15%; | |
| display: inline-block; /* Keep for status emoji alignment */ | |
| } | |
| .message { | |
| font-size: ${messageFontSize}px; /* UNIVERSAL SIZE */ | |
| line-height: ${messageLineHeight}; | |
| color: #fefcff; | |
| word-break: break-word; | |
| padding-bottom: ${10 * scale}px; | |
| text-align: left; | |
| /* max-width is now set dynamically in Puppeteer */ | |
| width: 100%; | |
| box-sizing: border-box; | |
| } | |
| .reply { | |
| background-color: ${replySenderColor}10; | |
| border-radius: ${10 * scale}px; | |
| position: relative; | |
| padding-left: ${12 * scale}px; | |
| padding-top: ${8 * scale}px; | |
| padding-bottom: ${8 * scale}px; | |
| padding-right: ${10 * scale}px; | |
| margin-bottom: ${replyMarginBottom}px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: flex-start; | |
| width: 100%; | |
| box-sizing: border-box; | |
| /* gap removed */ | |
| } | |
| .reply::before { | |
| content: ''; | |
| position: absolute; | |
| left: ${4 * scale}px; | |
| background-color: ${replySenderColor}; | |
| border-radius: ${2 * scale}px; | |
| width: ${4 * scale}px; | |
| top: ${8 * scale}px; | |
| bottom: ${8 * scale}px; | |
| } | |
| /* FIX: Remove flex, rely on inline-block alignment */ | |
| .reply-sender { | |
| display: flex; | |
| align-items: center; | |
| font-size: ${replySenderFontSize}px; /* Base size for 'em' unit */ | |
| font-weight: bold; | |
| color: ${replySenderColor}; | |
| margin-bottom: ${4 * scale}px; | |
| line-height: ${replyLineHeight}; | |
| text-align: left; | |
| padding-left: ${10 * scale}px; | |
| white-space: nowrap; | |
| } | |
| /* * Make all children of reply-sender align properly | |
| */ | |
| .reply-sender > * { | |
| display: block; | |
| } | |
| /* * KEY FIX (Repeated): Scale the reply sender's | |
| * text-image down to match the font-size. | |
| */ | |
| .reply-sender > .name-chunk-image { | |
| max-height: 1em; | |
| width: auto; | |
| } | |
| /* FIX: Use inline-block and vertical-align. Remove forced height. */ | |
| .reply-sender .name-chunk-image, .reply-sender .name-emoji { | |
| /* height: 1em; REMOVED */ | |
| display: inline-block; | |
| vertical-align: middle; | |
| } | |
| .reply-sender .name-chunk-image { | |
| /* height: ${replySenderFontSize}px !important; REMOVED -- THIS FIXES THE SIZE */ | |
| } | |
| .reply-message { | |
| font-size: ${replyMessageFontSize}px; /* UNIVERSAL SIZE */ | |
| line-height: ${replyLineHeight}; | |
| color: #b0b0b0; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| /* Replaced ellipsis with a fade-out gradient mask */ | |
| -webkit-mask-image: linear-gradient(to right, black 90%, transparent 100%); | |
| mask-image: linear-gradient(to right, black 90%, transparent 100%); | |
| text-align: left; | |
| padding-left: ${10 * scale}px; | |
| width: 100%; | |
| box-sizing: border-box; | |
| } | |
| </style></head> | |
| <body><div class="container" id="capture"> | |
| <img src="${avatarBase64}" class="avatar" /> | |
| <div class="bubble"> | |
| <div class="name-line"> | |
| ${nameContentHtml} | |
| ${emojiStatusBase64 ? `<img src="${emojiStatusBase64}" class="emoji-status" />` : ''} | |
| </div> | |
| ${replySender && replyMessage ? ` | |
| <div class="reply"> | |
| <div class="reply-sender">${replySenderHtml}</div> | |
| <div class="reply-message">${escapeHtml(processedReplyMessage)}</div> | |
| </div> | |
| ` : ''} | |
| <div class="message">${highlightedMessageHtml}</div> | |
| </div> | |
| </div></body></html>`; | |
| // ---PUPPETEER LAUNCH --- | |
| const AVATAR_WIDTH = 70 * scale; | |
| const AVATAR_MARGIN_RIGHT = 15 * scale; | |
| const BUBBLE_PADDING_HORIZONTAL = (25 + 25) * scale; // Left and right padding of the bubble | |
| const BUBBLE_TAIL_WIDTH = 20 * scale; // Width of the bubble tail (the '::before' element) | |
| const BODY_PADDING_HORIZONTAL = (30 + 30) * scale; // Left and right padding of the body | |
| const ESTIMATED_MAX_NAME_WIDTH = DEFAULT_MESSAGE_MAX_WIDTH * 1.5; | |
| const VIEWPORT_WIDTH = BODY_PADDING_HORIZONTAL + AVATAR_WIDTH + AVATAR_MARGIN_RIGHT + BUBBLE_TAIL_WIDTH + Math.max(ESTIMATED_MAX_NAME_WIDTH, DEFAULT_MESSAGE_MAX_WIDTH + BUBBLE_PADDING_HORIZONTAL) + (50 * scale); // Add extra buffer | |
| const VIEWPORT_HEIGHT = 1200 * scale; // Default height, will be cropped later | |
| const browser = await puppeteer.launch({ headless: true,executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox', '--disable-gpu','--no-proxy-server'] }); | |
| const page = await browser.newPage(); | |
| await page.setViewport({ width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT }); | |
| // Wait until dom is loaded (reverted from networkidle0) | |
| await page.setContent(htmlContent, { waitUntil: 'domcontentloaded' }); | |
| // *** FIX: Smart function to dynamically set message max-width *** | |
| await page.evaluate((defaultMessageWidth) => { | |
| // Measure the whole name line now that it contains images/spans | |
| const nameWidth = document.querySelector('.name-line')?.scrollWidth || 0; | |
| const replySenderElement = document.querySelector('.reply-sender'); | |
| const replyWidth = replySenderElement?.scrollWidth || 0; | |
| // Content width is the wider of the name line or reply sender | |
| const contentWidth = Math.max(nameWidth, replyWidth); | |
| // Message max-width is the wider of the content width or the default | |
| const newMaxWidth = Math.max(contentWidth, defaultMessageWidth); | |
| const messageElement = document.querySelector('.message'); | |
| if (messageElement) { | |
| messageElement.style.maxWidth = newMaxWidth + 'px'; | |
| } | |
| }, DEFAULT_MESSAGE_MAX_WIDTH); // Pass only the default width | |
| const element = await page.$('#capture'); | |
| const finalPngBuffer = await element.screenshot({ omitBackground: true }); | |
| await browser.close(); | |
| // fs.writeFileSync('temp_quote.png', finalPngBuffer); // De-comment for debugging | |
| // --- DYNAMIC SIZING WITH INVISIBLE BORDER --- | |
| const stickerWidth = 2048; | |
| const scaledPngBuffer = await sharp(finalPngBuffer) | |
| .resize({ width: stickerWidth, fit: 'inside', withoutEnlargement: true }) | |
| .toBuffer(); | |
| const scaledMetadata = await sharp(scaledPngBuffer).metadata(); | |
| const bubbleWidth = scaledMetadata.width || 0; | |
| const padding = Math.floor((stickerWidth - bubbleWidth) / 2); | |
| const webpBuffer = await sharp(scaledPngBuffer) | |
| .extend({ | |
| top: 0, | |
| bottom: 0, | |
| left: padding > 0 ? padding : 0, | |
| right: padding > 0 ? padding : 0, | |
| background: { r: 0, g: 0, b: 0, alpha: 0 } | |
| }) | |
| .webp({ quality: 90 }) | |
| .toBuffer(); | |
| return webpBuffer; | |
| } | |
| module.exports = createImage; |