diff --git a/client/src/components/preview-modal.tsx b/client/src/components/preview-modal.tsx index 21e82ef..7fcd3e9 100644 --- a/client/src/components/preview-modal.tsx +++ b/client/src/components/preview-modal.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { ChevronLeft, ChevronRight, Send, Facebook, Instagram, Heart, MessageCircle, Share2, Bookmark } from 'lucide-react'; import type { Media } from '@shared/schema'; -import { removeEmojis } from '@shared/emoji'; +import { removeHashtags } from '@shared/emoji'; interface PreviewModalProps { open: boolean; @@ -228,8 +228,8 @@ export function PreviewModal({ const ctx = canvas.getContext('2d'); if (!ctx) return [text]; - // Remove emojis for better text rendering - const cleanText = removeEmojis(text); + // Remove hashtags while keeping emojis + const cleanText = removeHashtags(text); ctx.font = `bold ${fontSize}px Arial, sans-serif`; const words = cleanText.split(' '); const lines: string[] = []; diff --git a/package-lock.json b/package-lock.json index 1a39110..6500294 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "@types/bcrypt": "^6.0.0", "@types/multer": "^2.0.0", "@types/node-cron": "^3.0.11", + "@types/node-fetch": "^2.6.13", "@types/pg": "^8.15.5", "bcrypt": "^6.0.0", "canvas": "^3.2.0", @@ -85,6 +86,7 @@ "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", + "twemoji-parser": "^14.0.0", "vaul": "^1.1.2", "wouter": "^3.3.5", "ws": "^8.18.0", @@ -4104,6 +4106,16 @@ "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", "license": "MIT" }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", @@ -4326,6 +4338,12 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/attr-accept": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", @@ -4621,6 +4639,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -4778,6 +4809,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -5092,6 +5135,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5297,6 +5349,20 @@ "zod": ">=3.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5379,13 +5445,10 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -5399,6 +5462,33 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -5722,6 +5812,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5821,16 +5927,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -5847,6 +5958,19 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz", @@ -5899,12 +6023,12 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5928,10 +6052,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -5940,11 +6064,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { "node": ">= 0.4" }, @@ -6509,6 +6636,15 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -8579,6 +8715,12 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/twemoji-parser": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz", + "integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==", + "license": "MIT" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/package.json b/package.json index c1437bd..2ea2cc0 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@types/bcrypt": "^6.0.0", "@types/multer": "^2.0.0", "@types/node-cron": "^3.0.11", + "@types/node-fetch": "^2.6.13", "@types/pg": "^8.15.5", "bcrypt": "^6.0.0", "canvas": "^3.2.0", @@ -87,6 +88,7 @@ "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", + "twemoji-parser": "^14.0.0", "vaul": "^1.1.2", "wouter": "^3.3.5", "ws": "^8.18.0", diff --git a/replit.md b/replit.md index 2e0fa94..5f7ef7a 100644 --- a/replit.md +++ b/replit.md @@ -8,6 +8,15 @@ Preferred communication style: Simple, everyday language. ## Recent Changes +### November 21, 2025 +- **Story Text Rendering with Emoji Support**: Implemented robust emoji rendering in Instagram/Facebook stories using Twemoji (Twitter's emoji library). Previous implementation removed all emojis due to canvas rendering limitations. New solution: + - **Hashtag Removal**: Changed from removing emojis to removing hashtags (`#tag`) for cleaner story text while preserving emojis for engagement + - **Twemoji Integration**: Created `emojiRenderer` service that converts text emojis to high-quality PNG images from Twemoji CDN + - **Smart Text Wrapping**: Enhanced text wrapping to handle long emoji sequences (e.g., "🎉🎉🎉🎉🎉") by breaking them into chunks that fit within canvas bounds + - **Emoji Caching**: Implements in-memory caching of emoji images to reduce CDN requests and improve performance + - **Grapheme-Aware Breaking**: Uses `Array.from()` for proper Unicode grapheme splitting, ensuring multi-codepoint emojis aren't broken incorrectly + - Files modified: `shared/emoji.ts`, `server/services/emojiRenderer.ts`, `server/services/imageProcessor.ts`, `client/src/components/preview-modal.tsx` + ### November 18, 2025 - **Facebook Video Codec Compatibility Fix**: Fixed issue where MP4 videos uploaded to Facebook would not play correctly due to codec incompatibility. Facebook requires strict video format: H.264 video codec, AAC audio codec, and MP4 container. Solution: Added automatic video transcoding via Cloudinary transformations. When publishing a video to Facebook, the system now transforms the Cloudinary URL to include `vc_h264,ac_aac,f_mp4,q_auto` parameters, ensuring all videos are automatically re-encoded to Facebook-compatible format before publication. The getFacebookCompatibleVideoUrl() method detects Cloudinary video URLs and applies the necessary transformations transparently. Non-Cloudinary URLs are passed through unchanged with a warning. This ensures reliable video playback on Facebook without requiring users to manually re-encode their videos. Files modified: `server/services/facebook.ts`. diff --git a/server/services/emojiRenderer.ts b/server/services/emojiRenderer.ts new file mode 100644 index 0000000..909dc03 --- /dev/null +++ b/server/services/emojiRenderer.ts @@ -0,0 +1,225 @@ +import { parse } from 'twemoji-parser'; +import { createCanvas, loadImage, CanvasRenderingContext2D } from 'canvas'; + +interface EmojiSegment { + type: 'text' | 'emoji'; + content: string; + url?: string; +} + +/** + * Service to render text with emojis on canvas using Twemoji + * Converts emojis to images and renders them alongside text + */ +export class EmojiRenderer { + private emojiCache = new Map(); + + /** + * Parse text into segments of text and emojis + */ + private parseTextWithEmojis(text: string): EmojiSegment[] { + const emojis = parse(text); + if (emojis.length === 0) { + return [{ type: 'text', content: text }]; + } + + const segments: EmojiSegment[] = []; + let lastIndex = 0; + + for (const emoji of emojis) { + // Add text before emoji + if (emoji.indices[0] > lastIndex) { + const textContent = text.substring(lastIndex, emoji.indices[0]); + if (textContent) { + segments.push({ type: 'text', content: textContent }); + } + } + + // Add emoji + segments.push({ + type: 'emoji', + content: emoji.text, + url: emoji.url + }); + + lastIndex = emoji.indices[1]; + } + + // Add remaining text + if (lastIndex < text.length) { + const textContent = text.substring(lastIndex); + if (textContent) { + segments.push({ type: 'text', content: textContent }); + } + } + + return segments; + } + + /** + * Download and cache emoji image + */ + private async getEmojiImage(url: string): Promise { + if (this.emojiCache.has(url)) { + return this.emojiCache.get(url)!; + } + + const response = await fetch(url); + const buffer = Buffer.from(await response.arrayBuffer()); + this.emojiCache.set(url, buffer); + return buffer; + } + + /** + * Measure the width of text with emojis + */ + async measureText(text: string, ctx: CanvasRenderingContext2D, fontSize: number): Promise { + const segments = this.parseTextWithEmojis(text); + let totalWidth = 0; + + for (const segment of segments) { + if (segment.type === 'text') { + totalWidth += ctx.measureText(segment.content).width; + } else { + // Emoji width is approximately equal to font size + totalWidth += fontSize; + } + } + + return totalWidth; + } + + /** + * Draw text with emojis on canvas + */ + async drawText( + text: string, + ctx: CanvasRenderingContext2D, + x: number, + y: number, + fontSize: number + ): Promise { + const segments = this.parseTextWithEmojis(text); + let currentX = x; + + for (const segment of segments) { + if (segment.type === 'text') { + ctx.fillText(segment.content, currentX, y); + currentX += ctx.measureText(segment.content).width; + } else if (segment.url) { + try { + const emojiBuffer = await this.getEmojiImage(segment.url); + const emojiImage = await loadImage(emojiBuffer); + + // Draw emoji slightly above baseline to align with text + const emojiSize = fontSize; + const emojiY = y - fontSize * 0.8; // Adjust vertical position + + ctx.drawImage(emojiImage, currentX, emojiY, emojiSize, emojiSize); + currentX += emojiSize; + } catch (error) { + console.error('Error loading emoji:', error); + // Fallback: draw text representation + ctx.fillText(segment.content, currentX, y); + currentX += ctx.measureText(segment.content).width; + } + } + } + } + + /** + * Break a long word that exceeds maxWidth into chunks + */ + private async breakLongWord( + word: string, + ctx: CanvasRenderingContext2D, + maxWidth: number, + fontSize: number + ): Promise { + const chunks: string[] = []; + let currentChunk = ''; + + // Split word into individual characters (graphemes for emoji support) + const chars = Array.from(word); + + for (const char of chars) { + const testChunk = currentChunk + char; + const width = await this.measureText(testChunk, ctx, fontSize); + + if (width > maxWidth && currentChunk) { + chunks.push(currentChunk); + currentChunk = char; + } else { + currentChunk = testChunk; + } + } + + if (currentChunk) { + chunks.push(currentChunk); + } + + return chunks; + } + + /** + * Wrap text with emojis into multiple lines + * Handles long words/emoji sequences by breaking them when needed + */ + async wrapText( + text: string, + ctx: CanvasRenderingContext2D, + maxWidth: number, + fontSize: number + ): Promise { + const words = text.split(' '); + const lines: string[] = []; + let currentLine = ''; + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + const testWidth = await this.measureText(testLine, ctx, fontSize); + + if (testWidth > maxWidth && currentLine) { + // Current line would exceed width, push it and start new line + lines.push(currentLine); + currentLine = word; + + // Check if the word itself is too long for a single line + const wordWidth = await this.measureText(word, ctx, fontSize); + if (wordWidth > maxWidth) { + // Word is too long, need to break it into chunks + const chunks = await this.breakLongWord(word, ctx, maxWidth, fontSize); + for (let i = 0; i < chunks.length - 1; i++) { + lines.push(chunks[i]); + } + currentLine = chunks[chunks.length - 1]; + } + } else if (testWidth > maxWidth && !currentLine) { + // First word is too long, need to break it + const chunks = await this.breakLongWord(word, ctx, maxWidth, fontSize); + for (let i = 0; i < chunks.length - 1; i++) { + lines.push(chunks[i]); + } + currentLine = chunks[chunks.length - 1]; + } else { + currentLine = testLine; + } + } + + if (currentLine) { + lines.push(currentLine); + } + + return lines; + } + + /** + * Clear emoji cache + */ + clearCache(): void { + this.emojiCache.clear(); + } +} + +// Singleton instance +export const emojiRenderer = new EmojiRenderer(); diff --git a/server/services/imageProcessor.ts b/server/services/imageProcessor.ts index e63bcba..5334db5 100644 --- a/server/services/imageProcessor.ts +++ b/server/services/imageProcessor.ts @@ -3,7 +3,8 @@ import { randomUUID } from "crypto"; import path from "path"; import fs from "fs/promises"; import { createCanvas, loadImage, registerFont } from "canvas"; -import { removeEmojis } from "@shared/emoji"; +import { removeHashtags } from "@shared/emoji"; +import { emojiRenderer } from "./emojiRenderer"; interface CropDimensions { width: number; @@ -118,8 +119,8 @@ export class ImageProcessor { const PADDING = 40; const LINE_HEIGHT_MULTIPLIER = 1.3; - // Remove emojis from text as canvas doesn't support them - const cleanText = removeEmojis(text); + // Remove hashtags from text while keeping emojis + const cleanText = removeHashtags(text); const canvas = createCanvas(STORY_WIDTH, STORY_HEIGHT); const ctx = canvas.getContext('2d'); @@ -165,7 +166,7 @@ export class ImageProcessor { while (fontSize >= MIN_FONT_SIZE) { ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`; - lines = this.wrapText(ctx, cleanText, maxTextWidth); + lines = await emojiRenderer.wrapText(cleanText, ctx, maxTextWidth, fontSize); const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; totalTextHeight = lines.length * lineHeight; @@ -179,7 +180,7 @@ export class ImageProcessor { if (fontSize < MIN_FONT_SIZE) { fontSize = MIN_FONT_SIZE; ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`; - lines = this.wrapText(ctx, cleanText, maxTextWidth); + lines = await emojiRenderer.wrapText(cleanText, ctx, maxTextWidth, fontSize); const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; totalTextHeight = lines.length * lineHeight; @@ -194,16 +195,19 @@ export class ImageProcessor { } ctx.fillStyle = 'white'; - ctx.textAlign = 'center'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; const startY = textBoxY + (textBoxHeight - totalTextHeight) / 2 + lineHeight / 2; - lines.forEach((line, index) => { + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; const y = startY + (index * lineHeight); - ctx.fillText(line, STORY_WIDTH / 2, y); - }); + const lineWidth = await emojiRenderer.measureText(line, ctx, fontSize); + const x = (STORY_WIDTH - lineWidth) / 2; // Center the line + await emojiRenderer.drawText(line, ctx, x, y, fontSize); + } return canvas.toBuffer('image/png'); } diff --git a/server/services/twemoji-parser.d.ts b/server/services/twemoji-parser.d.ts new file mode 100644 index 0000000..4d8821b --- /dev/null +++ b/server/services/twemoji-parser.d.ts @@ -0,0 +1,11 @@ +declare module 'twemoji-parser' { + export interface EmojiEntity { + text: string; + url: string; + indices: [number, number]; + type: string; + } + + export function parse(text: string, options?: { buildUrl?: (codePoint: string, assetType: string) => string }): EmojiEntity[]; + export function toCodePoints(unicodeSurrogates: string): string[]; +} diff --git a/shared/emoji.ts b/shared/emoji.ts index 41e6231..a7cc9a7 100644 --- a/shared/emoji.ts +++ b/shared/emoji.ts @@ -1,16 +1,10 @@ -import emojiRegex from 'emoji-regex'; - -// Cached regex instance for performance -const emojiPattern = emojiRegex(); - /** - * Removes emojis from text while preserving other Unicode characters - * Uses the emoji-regex package which correctly identifies emojis without - * removing legitimate astral-plane characters (CJK Extension B, etc.) + * Removes hashtags from text while preserving emojis and other content + * Matches patterns like #hashtag, #HashTag123, etc. */ -export function removeEmojis(text: string): string { +export function removeHashtags(text: string): string { return text - .replace(emojiPattern, '') + .replace(/#[\w\u00C0-\u017F]+/g, '') // Remove hashtags (supports accented characters) .replace(/\s+/g, ' ') // Normalize whitespace .trim(); }