Please bookmark this page to avoid losing your image tool!

Image Thought Bubble Adder

(Free & Supports Bulk Upload)

Drag & drop your images here or

The result will appear here...
You can edit the below JavaScript code to customize the image tool.
function processImage(originalImg, text = "Hmm...", targetX = -1, targetY = -1, bubbleCenterXinPercentage = 50, bubbleCenterYinPercentage = 25, fontSize = 20, fontFamily = "Arial", textColor = "black", bubbleFillColor = "white", bubbleStrokeColor = "black", lineWidth = 2, bubblePadding = 15, maxBubbleWidthPercentage = 80, lineSpacingFactor = 1.2) {

    // Set default targetX and targetY based on image dimensions if they are -1 (or not provided and defaulted)
    if (targetX === -1) {
        targetX = originalImg.width / 2;
    }
    if (targetY === -1) {
        targetY = originalImg.height * 0.75;
    }

    const canvas = document.createElement('canvas');
    canvas.width = originalImg.width;
    canvas.height = originalImg.height;
    const ctx = canvas.getContext('2d');

    // Draw the original image
    ctx.drawImage(originalImg, 0, 0);

    // --- Text and Bubble Sizing ---
    ctx.font = `${fontSize}px ${fontFamily}`;
    
    // Helper function to wrap text
    function _wrapText(context, textToWrap, maxWidth) {
        const lines = [];
        const paragraphs = textToWrap.split('\n');

        for (const paragraph of paragraphs) {
            if (paragraph === "") {
                lines.push({ text: "", width: 0 });
                continue;
            }
            let words = paragraph.split(' ');
            let currentLineText = "";
            
            for (let i = 0; i < words.length; i++) {
                let word = words[i];
                // If currentLineText is empty, word is first on this line.
                // If not, add a space before the new word.
                let testLineText = currentLineText ? currentLineText + " " + word : word;
                let testLineWidth = context.measureText(testLineText).width;

                if (testLineWidth <= maxWidth) {
                    currentLineText = testLineText;
                } else {
                    // Word does not fit.
                    // If there's content in currentLineText (it's not the first word), push it.
                    if (currentLineText) {
                        lines.push({ text: currentLineText, width: context.measureText(currentLineText).width });
                    }
                    // Start new line with current word. It may overflow if too long by itself.
                    currentLineText = word; 
                }
            }
            // Push any remaining part of the line
            if (currentLineText) {
                lines.push({ text: currentLineText, width: context.measureText(currentLineText).width });
            } else if (words.length === 0 && paragraph) { 
                 lines.push({ text: "", width: 0 });
            }
        }
        if (lines.length === 0 && textToWrap.length > 0) { // Handle if text is one short line no spaces no newlines
            lines.push({ text: textToWrap, width: context.measureText(textToWrap).width });
        } else if (lines.length === 0 && textToWrap.length === 0) { // Handle empty text
             lines.push({ text: "", width: 0 });
        }
        return lines;
    }

    const availableTextWidth = (canvas.width * (maxBubbleWidthPercentage / 100)) - (2 * bubblePadding);
    const wrappedLines = _wrapText(ctx, text, availableTextWidth);

    let actualTextMaxWidth = 0;
    for (const line of wrappedLines) {
        if (line.width > actualTextMaxWidth) {
            actualTextMaxWidth = line.width;
        }
    }

    let textBlockHeight;
    if (wrappedLines.length > 0) {
        textBlockHeight = (wrappedLines.length -1) * fontSize * lineSpacingFactor + fontSize; // last line doesn't need full spacingFactor below
    } else {
        textBlockHeight = fontSize; // For empty text string case
    }


    const mainBubbleWidth = actualTextMaxWidth + 2 * bubblePadding;
    const mainBubbleHeight = textBlockHeight + 2 * bubblePadding;

    const bubbleCenterX = canvas.width * (bubbleCenterXinPercentage / 100);
    const bubbleCenterY = canvas.height * (bubbleCenterYinPercentage / 100);

    const ellipseRadiusX = mainBubbleWidth / 2;
    const ellipseRadiusY = mainBubbleHeight / 2;

    // --- Draw Main Bubble (Ellipse) ---
    ctx.beginPath();
    ctx.ellipse(bubbleCenterX, bubbleCenterY, ellipseRadiusX, ellipseRadiusY, 0, 0, 2 * Math.PI);
    ctx.fillStyle = bubbleFillColor;
    ctx.fill();
    ctx.strokeStyle = bubbleStrokeColor;
    ctx.lineWidth = lineWidth;
    ctx.stroke();

    // --- Draw Tail Bubbles ---
    const numTailCircles = 3;
    const firstTailRadius = Math.max(3, Math.min(ellipseRadiusX, ellipseRadiusY) * 0.30); // ensure not too small

    const dxToTarget = targetX - bubbleCenterX;
    const dyToTarget = targetY - bubbleCenterY;
    
    // Check if target is inside or very close to the main bubble's center
    const isTargetInsideOrAtCenter = (Math.pow(dxToTarget / (ellipseRadiusX || 1), 2) + Math.pow(dyToTarget / (ellipseRadiusY || 1), 2)) < 1.01; // Add tolerance

    if (!isTargetInsideOrAtCenter && (ellipseRadiusX > 0 && ellipseRadiusY > 0)) {
        let tIntersect = 1;
        // Avoid division by zero if ellipse has zero radius in one dimension
        if (ellipseRadiusX !== 0 && ellipseRadiusY !== 0) {
            const denominator = Math.sqrt(Math.pow(dxToTarget / ellipseRadiusX, 2) + Math.pow(dyToTarget / ellipseRadiusY, 2));
            if (denominator !== 0) { // Avoid division by zero if target is at center
                 tIntersect = 1 / denominator;
            } else { // target is at bubble center, effectively handled by isTargetInsideOrAtCenter
                tIntersect = 0; // No tail needed
            }
        } else {
            tIntersect = 0; // No tail for degenerate ellipse
        }


        const ellipseEdgeX = bubbleCenterX + tIntersect * dxToTarget;
        const ellipseEdgeY = bubbleCenterY + tIntersect * dyToTarget;

        const tailRadii = [];
        for (let i = 0; i < numTailCircles; i++) {
            tailRadii.push(Math.max(lineWidth, firstTailRadius * Math.pow(0.65, i))); // Ensure radius is at least lineWidth
        }

        for (let i = 0; i < numTailCircles; i++) {
            // Spacing circles from ellipse edge towards target
            // (i + 0.5) / numTailCircles provides even spacing of centers
            const placementFactor = (i + 0.8) / (numTailCircles + 0.5); // Adjusted for better visual spacing

            const circleCenterX = ellipseEdgeX + (targetX - ellipseEdgeX) * placementFactor;
            const circleCenterY = ellipseEdgeY + (targetY - ellipseEdgeY) * placementFactor;
            const radius = tailRadii[i];

            ctx.beginPath();
            ctx.arc(circleCenterX, circleCenterY, radius, 0, 2 * Math.PI);
            ctx.fillStyle = bubbleFillColor;
            ctx.fill();
            ctx.strokeStyle = bubbleStrokeColor;
            ctx.lineWidth = Math.max(1, lineWidth / 2); // Tail bubbles can have thinner lines
            ctx.stroke();
        }
    }


    // --- Draw Text ---
    ctx.fillStyle = textColor;
    ctx.font = `${fontSize}px ${fontFamily}`;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";

    const totalTextRenderHeight = (wrappedLines.length > 1) ? (wrappedLines.length - 1) * fontSize * lineSpacingFactor : 0;
    let currentY = bubbleCenterY - totalTextRenderHeight / 2;
    
    if (wrappedLines.length === 1) { // Single line of text
        currentY = bubbleCenterY; // Center it vertically directly
    }


    for (const line of wrappedLines) {
        ctx.fillText(line.text, bubbleCenterX, currentY);
        currentY += fontSize * lineSpacingFactor;
    }

    return canvas;
}

Free Image Tool Creator

Can't find the image tool you're looking for?
Create one based on your own needs now!

Description

The Image Thought Bubble Adder is a tool that allows users to add customizable thought bubbles to images, making it ideal for creating memes, illustrative content, and social media graphics. Users can specify the text to be included in the bubble, as well as its positioning, font style, color, and bubble design specifications such as size and padding. This tool is particularly useful for educators, marketers, and content creators looking to enhance visual storytelling by emphasizing thoughts or ideas in a visually appealing format.

Leave a Reply

Your email address will not be published. Required fields are marked *

Other Image Tools:

Image To Braille Pattern Art Converter

Image To Mathematical Symbols Art Converter

Image To ANSI Terminal Art Converter

Image Dashed Line Adder

Image To Dingbat Symbol Art Converter

Image Custom Polygon Cropper

Image Rule Of Thirds Grid Overlay Tool

Image Resizer for Social Media Platforms

Image Circular Crop Tool

Image Date Stamp Adder

Image Circular Guides Adder

Image Center Cropper

Image Petzval Lens Swirly Bokeh Effect Creator

Image Mimiya 645 Medium Format Filter Effect Tool

Photo Fujifilm Klasse W Filter Effect Application

Image Deardorff Large Format Filter Effect Application

Image Lomo LC-A Filter Effect Tool

Image Large Format Filter Effect Application

Image Zone Plate Lens Effect Creator

Photo Kodak Retina Filter Effect Tool

Image Polaroid 600 Filter Effect Tool

Photo Black and White Yellow Filter Effect Tool

Image Contax G2 Film Camera Render Effect Applicator

Image 110 Film Format Filter Effect Tool

Photo Jupiter-9 Portrait Lens Filter Effect

Image Fujifilm GW690 Texas Leica Filter Effect Application

Image Zeiss T* Coating Filter Effect Tool

Image Hoya R72 Infrared Filter Effect Tool

Image Filter Effect for Zeiss Ikon Contaflex

Photo Olympus Mju-II/Stylus Epic Filter Effect Tool

Image NiSi Nano IR ND Filter Effect Tool

Image Polaroid SX-70 Filter Effect Tool

Image Linhof Technika Filter Effect Tool

Image Lee Big Stopper 10-Stop ND Filter Effect Tool

Image Minolta X-700 Film Camera Render Effect Creator

Image ORWO UN54 Motion Picture Film Effect Applicator

See All →