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/server/services/emojiRenderer.ts b/server/services/emojiRenderer.ts index 9137b6b..909dc03 100644 --- a/server/services/emojiRenderer.ts +++ b/server/services/emojiRenderer.ts @@ -1,6 +1,5 @@ import { parse } from 'twemoji-parser'; import { createCanvas, loadImage, CanvasRenderingContext2D } from 'canvas'; -import fetch from 'node-fetch'; interface EmojiSegment { type: 'text' | 'emoji'; @@ -128,8 +127,43 @@ export class EmojiRenderer { } } + /** + * 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, @@ -146,8 +180,27 @@ export class EmojiRenderer { 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; } 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[]; +}