Please bookmark this page to avoid losing your image tool!

Image Speech 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.
async function processImage(originalImg, text = "Hello!", x = 50, y = 50, bubbleWidth = 150, bubbleHeight = 70, tailDirection = "bottom-left", tailLength = 20, tailWidth = 15, backgroundColor = "white", borderColor = "black", borderWidth = 2, textColor = "black", fontFamily = "Arial", fontSize = 14, padding = 10, cornerRadius = 10) {

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

    canvas.width = originalImg.width;
    canvas.height = originalImg.height;

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

    // --- Text Wrapping ---
    ctx.font = `${fontSize}px ${fontFamily}`;
    const lineHeight = fontSize * 1.2;
    const textLines = [];
    let currentLine = '';
    const words = String(text).split(' ');
    const maxContentWidth = bubbleWidth - 2 * padding;

    for (let i = 0; i < words.length; i++) {
        const testLine = currentLine + words[i] + (i === words.length - 1 ? '' : ' ');
        const metrics = ctx.measureText(testLine);
        if (metrics.width > maxContentWidth && currentLine !== '') {
            textLines.push(currentLine.trim());
            currentLine = words[i] + ' ';
        } else {
            currentLine = testLine;
        }
    }
    textLines.push(currentLine.trim());

    const textBlockWidth = Math.max(...textLines.map(line => ctx.measureText(line).width));
    const textBlockHeight = textLines.length * lineHeight - (lineHeight - fontSize); // More accurate height

    const finalBubbleContentW = textBlockWidth;
    const finalBubbleContentH = textBlockHeight;

    const finalBubbleW = finalBubbleContentW + 2 * padding;
    const finalBubbleH = finalBubbleContentH + 2 * padding;

    // --- Bubble and Tail Geometry ---
    let rectX, rectY; // Top-left of the bubble rectangle
    let attach1X, attach1Y, attach2X, attach2Y; // Tail attachment points on bubble edge
    let tipX = x;
    let tipY = y;

    // Ensure borderWidth is not negative
    borderWidth = Math.max(0, borderWidth);

    const settingsMap = {
        // edge: which edge tail comes from.
        // edgePos: fractional position along that edge (0=start, 0.5=middle, 1=end)
        //          start/end refers to visual start/end (e.g. bottom edge: 0 = left, 1 = right)
        // defDir: default normalized direction vector for tail if tip and anchor coincide
        "none": { edge: "none" },
        "bottom-left":  { edge: "bottom", edgePos: 0.25, defDir: { x: -0.5, y: 0.866 } },
        "bottom":       { edge: "bottom", edgePos: 0.5,  defDir: { x: 0, y: 1 } },
        "bottom-right": { edge: "bottom", edgePos: 0.75, defDir: { x: 0.5, y: 0.866 } },
        "top-left":     { edge: "top",    edgePos: 0.25, defDir: { x: -0.5, y: -0.866 } },
        "top":          { edge: "top",    edgePos: 0.5,  defDir: { x: 0, y: -1 } },
        "top-right":    { edge: "top",    edgePos: 0.75, defDir: { x: 0.5, y: -0.866 } },
        "left-top":     { edge: "left",   edgePos: 0.25, defDir: { x: -0.866, y: -0.5 } },
        "left":         { edge: "left",   edgePos: 0.5,  defDir: { x: -1, y: 0 } },
        "left-bottom":  { edge: "left",   edgePos: 0.75, defDir: { x: -0.866, y: 0.5 } },
        "right-top":    { edge: "right",  edgePos: 0.25, defDir: { x: 0.866, y: -0.5 } },
        "right":        { edge: "right",  edgePos: 0.5,  defDir: { x: 1, y: 0 } },
        "right-bottom": { edge: "right",  edgePos: 0.75, defDir: { x: 0.866, y: 0.5 } }
    };

    const s = settingsMap[tailDirection] || settingsMap["bottom"]; // Default to "bottom" if invalid

    if (s.edge === "none" || tailLength <= 0) {
        rectX = tipX - finalBubbleW / 2; // Center bubble at tipX if no tail
        rectY = tipY - finalBubbleH / 2; // Center bubble at tipY
        if (tailLength <=0 && s.edge !== "none") { // Position bubble adjacent to tip if tailLength is 0 but direction is specified
             if (s.edge === "bottom") rectY = tipY - finalBubbleH;
             else if (s.edge === "top") rectY = tipY;
             else if (s.edge === "left") rectX = tipX;
             else if (s.edge === "right") rectX = tipX - finalBubbleW;
        }

    } else {
        let bubbleAnchorMidRelX, bubbleAnchorMidRelY; // Midpoint of tail base, relative to bubble's (0,0)

        if (s.edge === "bottom") {
            bubbleAnchorMidRelX = finalBubbleW * s.edgePos;
            bubbleAnchorMidRelY = finalBubbleH;
        } else if (s.edge === "top") {
            bubbleAnchorMidRelX = finalBubbleW * s.edgePos;
            bubbleAnchorMidRelY = 0;
        } else if (s.edge === "left") {
            bubbleAnchorMidRelX = 0;
            bubbleAnchorMidRelY = finalBubbleH * s.edgePos;
        } else { // right
            bubbleAnchorMidRelX = finalBubbleW;
            bubbleAnchorMidRelY = finalBubbleH * s.edgePos;
        }

        // Calculate normalized direction from bubble anchor to tip
        // Initial guess for anchor point is the tip itself, to get default direction for zero distance
        let dx = tipX - (tipX); // tipX - (initialRectX + bubbleAnchorMidRelX) -> dx = 0
        let dy = tipY - (tipY); // tipY - (initialRectY + bubbleAnchorMidRelY) -> dy = 0
        let dist = Math.sqrt(dx * dx + dy * dy);
        
        let normDirX, normDirY;
        if (dist < 0.001) {
            normDirX = s.defDir.x;
            normDirY = s.defDir.y;
        } else {
            normDirX = dx / dist;
            normDirY = dy / dist;
        }

        // Final absolute position of the tail's midpoint on the bubble edge
        const finalBubbleAnchorMidAbsX = tipX - normDirX * tailLength;
        const finalBubbleAnchorMidAbsY = tipY - normDirY * tailLength;

        // Calculate final bubble rectangle top-left (rectX, rectY)
        rectX = finalBubbleAnchorMidAbsX - bubbleAnchorMidRelX;
        rectY = finalBubbleAnchorMidAbsY - bubbleAnchorMidRelY;

        // Calculate tail attachment points on the bubble edge
        if (s.edge === "bottom" || s.edge === "top") {
            attach1X = finalBubbleAnchorMidAbsX - tailWidth / 2;
            attach1Y = finalBubbleAnchorMidAbsY;
            attach2X = finalBubbleAnchorMidAbsX + tailWidth / 2;
            attach2Y = finalBubbleAnchorMidAbsY;
        } else { // left or right
            attach1X = finalBubbleAnchorMidAbsX;
            attach1Y = finalBubbleAnchorMidAbsY - tailWidth / 2;
            attach2X = finalBubbleAnchorMidAbsX;
            attach2Y = finalBubbleAnchorMidAbsY + tailWidth / 2;
        }
    }

    // --- Drawing ---
    ctx.fillStyle = backgroundColor;
    ctx.strokeStyle = borderColor;
    ctx.lineWidth = borderWidth;

    // Ensure cornerRadius is not too large
    const r = Math.min(cornerRadius, finalBubbleW / 2, finalBubbleH / 2);


    ctx.beginPath();
    if (s.edge !== "none" && tailLength > 0) {
        // Determine which attachment point is "first" for CCW path
        let p1x, p1y, p2x, p2y;
        if (s.edge === "bottom" ) { // right point first for CCW
            p1x = Math.max(attach1X, attach2X); p1y = attach1Y;
            p2x = Math.min(attach1X, attach2X); p2y = attach2Y;
        } else if (s.edge === "top") { // left point first for CCW
            p1x = Math.min(attach1X, attach2X); p1y = attach1Y;
            p2x = Math.max(attach1X, attach2X); p2y = attach2Y;
        } else if (s.edge === "left") { // bottom point first for CCW
            p1x = attach1X; p1y = Math.max(attach1Y, attach2Y);
            p2x = attach2X; p2y = Math.min(attach1Y, attach2Y);
        } else { // right, top point first for CCW
             p1x = attach1X; p1y = Math.min(attach1Y, attach2Y);
             p2x = attach2X; p2y = Math.max(attach1Y, attach2Y);
        }


        ctx.moveTo(tipX, tipY);
        ctx.lineTo(p1x, p1y);

        // Draw perimeter from p1 to p2 (CCW)
        // This simplified path logic connects p1 to the nearest corner, goes around, then connects to p2.
        // More robust solution would handle p1/p2 being on arcs.
        if (s.edge === "bottom") {
            ctx.lineTo(rectX + finalBubbleW - r, rectY + finalBubbleH); // P4
            ctx.arcTo(rectX + finalBubbleW, rectY + finalBubbleH, rectX + finalBubbleW, rectY + finalBubbleH - r, r); // BR
            ctx.lineTo(rectX + finalBubbleW, rectY + r); // P2
            ctx.arcTo(rectX + finalBubbleW, rectY, rectX + finalBubbleW - r, rectY, r); // TR
            ctx.lineTo(rectX + r, rectY); // P0
            ctx.arcTo(rectX, rectY, rectX, rectY + r, r); // TL
            ctx.lineTo(rectX, rectY + finalBubbleH - r); // P6
            ctx.arcTo(rectX, rectY + finalBubbleH, rectX + r, rectY + finalBubbleH, r); // BL
            ctx.lineTo(p2x, p2y);
        } else if (s.edge === "top") {
            ctx.lineTo(rectX + r, rectY); // P0
            ctx.arcTo(rectX, rectY, rectX, rectY + r, r); // TL
            ctx.lineTo(rectX, rectY + finalBubbleH - r); // P6
            ctx.arcTo(rectX, rectY + finalBubbleH, rectX + r, rectY + finalBubbleH, r); // BL
            ctx.lineTo(rectX + finalBubbleW - r, rectY + finalBubbleH); // P4
            ctx.arcTo(rectX + finalBubbleW, rectY + finalBubbleH, rectX + finalBubbleW, rectY + finalBubbleH - r, r); // BR
            ctx.lineTo(rectX + finalBubbleW, rectY + r); // P2
            ctx.arcTo(rectX + finalBubbleW, rectY, rectX + finalBubbleW - r, rectY, r); // TR
            ctx.lineTo(p2x, p2y);
        } else if (s.edge === "left") {
            ctx.lineTo(rectX, rectY + finalBubbleH - r); //P6
            ctx.arcTo(rectX, rectY + finalBubbleH, rectX + r, rectY + finalBubbleH, r); //BL
            ctx.lineTo(rectX + finalBubbleW - r, rectY + finalBubbleH); //P4
            ctx.arcTo(rectX + finalBubbleW, rectY + finalBubbleH, rectX + finalBubbleW, rectY + finalBubbleH - r, r); //BR
            ctx.lineTo(rectX + finalBubbleW, rectY + r); //P2
            ctx.arcTo(rectX + finalBubbleW, rectY, rectX + finalBubbleW - r, rectY, r); //TR
            ctx.lineTo(rectX + r, rectY); //P0
            ctx.arcTo(rectX, rectY, rectX, rectY + r, r); //TL
            ctx.lineTo(p2x, p2y);
        } else { // "right"
            ctx.lineTo(rectX + finalBubbleW, rectY + r); //P2
            ctx.arcTo(rectX + finalBubbleW, rectY, rectX + finalBubbleW - r, rectY, r); //TR
            ctx.lineTo(rectX + r, rectY); //P0
            ctx.arcTo(rectX, rectY, rectX, rectY + r, r); //TL
            ctx.lineTo(rectX, rectY + finalBubbleH - r); //P6
            ctx.arcTo(rectX, rectY + finalBubbleH, rectX + r, rectY + finalBubbleH, r); //BL
            ctx.lineTo(rectX + finalBubbleW - r, rectY + finalBubbleH); //P4
            ctx.arcTo(rectX + finalBubbleW, rectY + finalBubbleH, rectX + finalBubbleW, rectY + finalBubbleH - r, r); //BR
            ctx.lineTo(p2x, p2y);
        }
        ctx.closePath();

    } else { // No tail or zero length tail, just draw the rounded rectangle
        ctx.moveTo(rectX + r, rectY);
        ctx.lineTo(rectX + finalBubbleW - r, rectY);
        ctx.arcTo(rectX + finalBubbleW, rectY, rectX + finalBubbleW, rectY + r, r);
        ctx.lineTo(rectX + finalBubbleW, rectY + finalBubbleH - r);
        ctx.arcTo(rectX + finalBubbleW, rectY + finalBubbleH, rectX + finalBubbleW - r, rectY + finalBubbleH, r);
        ctx.lineTo(rectX + r, rectY + finalBubbleH);
        ctx.arcTo(rectX, rectY + finalBubbleH, rectX, rectY + finalBubbleH - r, r);
        ctx.lineTo(rectX, rectY + r);
        ctx.arcTo(rectX, rectY, rectX + r, rectY, r);
        ctx.closePath();
    }

    ctx.fill();
    if (borderWidth > 0) {
        ctx.stroke();
    }

    // --- Draw Text ---
    ctx.fillStyle = textColor;
    ctx.textAlign = "left";
    ctx.textBaseline = "top"; // More consistent for multi-line
    const textStartX = rectX + padding;
    let textCurrentY = rectY + padding;

    for (let i = 0; i < textLines.length; i++) {
        ctx.fillText(textLines[i], textStartX, textCurrentY);
        textCurrentY += lineHeight;
    }

    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 Speech Bubble Adder is a versatile online tool that allows users to add customizable speech bubbles to their images. With this tool, you can input any image and specify text to display within a speech bubble, adjusting parameters such as bubble size, position, tail direction, colors, and font styles. This feature is particularly useful for creating engaging graphics for social media posts, tutorials, presentations, or any content that benefits from visual dialogue or commentary.

Leave a Reply

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