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