You can edit the below JavaScript code to customize the image tool.
Apply Changes
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;
}
Apply Changes