You can edit the below JavaScript code to customize the image tool.
Apply Changes
async function processImage(
originalImg,
parchmentColor = "rgb(240, 220, 180)",
imageFilter = "sepia(0.7) contrast(1.1) brightness(0.9)",
imageOpacity = 0.85,
noiseOpacity = 0.08,
stainColor = "rgba(165, 120, 80, 0.15)",
numStains = 25,
maxStainSize = 120,
vignetteColor = "rgba(0, 0, 0, 0.5)",
vignetteStrength = 0.7,
edgeIrregularity = 15,
edgeDarkeningFactor = 0.4,
textFontFamily = "MedievalSharp",
textSize = "20", // Kept as string to combine with 'px'
textColor = "rgba(50, 40, 30, 0.8)",
textContent = "Incantamentum scriptum est. Cave ne lector ignarus verba nefanda proferat. Periculum magnum inest."
) {
const baseWidth = originalImg.naturalWidth;
const baseHeight = originalImg.naturalHeight;
const padding = edgeIrregularity * 2; // Padding for torn edges effect
// Canvas for primary content (background, image, text). Will be clipped later.
const contentCanvas = document.createElement('canvas');
contentCanvas.width = baseWidth + padding;
contentCanvas.height = baseHeight + padding;
const ctx = contentCanvas.getContext('2d');
// Center drawing operations for content within the padded canvas
ctx.translate(padding / 2, padding / 2);
// --- Font Loading ---
let parsedTextSize = parseInt(textSize);
if (isNaN(parsedTextSize) || parsedTextSize <= 0) parsedTextSize = 20;
let currentFontSetting = `${parsedTextSize}px '${textFontFamily}'`;
const fallbackFontSetting = `${parsedTextSize}px 'Georgia', serif`;
const safeFonts = ['Georgia', 'serif', 'Times New Roman', 'Times', 'Arial', 'sans-serif', 'Courier New', 'monospace'];
if (!safeFonts.some(sf => textFontFamily.toLowerCase().includes(sf.toLowerCase()))) {
try {
const fontNameForCSS = textFontFamily.replace(/\s+/g, '+');
const linkId = `font-stylesheet-${fontNameForCSS.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
if (!document.getElementById(linkId)) {
const link = document.createElement('link');
link.id = linkId;
link.href = `https://fonts.googleapis.com/css2?family=${fontNameForCSS}:wght@400&display=swap`;
link.rel = 'stylesheet';
const fontLinkPromise = new Promise((resolve, reject) => {
link.onload = resolve;
link.onerror = () => reject(new Error(`CSS for font '${textFontFamily}' failed to load.`));
setTimeout(() => reject(new Error(`Timeout loading CSS for font '${textFontFamily}'.`)), 3000); // 3s timeout for CSS
});
document.head.appendChild(link);
await fontLinkPromise;
}
// Try to ensure font is ready for canvas.
const fontLoadPromise = document.fonts.load(currentFontSetting);
const fontTimeoutPromise = new Promise((_,reject) => setTimeout(() => reject(new Error("Font activation timeout")), 2000)); // 2s for font activation
await Promise.race([fontLoadPromise, fontTimeoutPromise]);
if (!document.fonts.check(currentFontSetting)) {
// Brief pause for safety, sometimes check is false immediately after load resolves
await new Promise(resolve => setTimeout(resolve, 100));
if (!document.fonts.check(currentFontSetting)) {
console.warn(`Font '${currentFontSetting}' not confirmed active post-load. Visuals might vary.`);
}
}
} catch (e) {
console.warn(`Font loading/activation for '${textFontFamily}' failed: ${e.message}. Using fallback: ${fallbackFontSetting}`);
currentFontSetting = fallbackFontSetting;
}
}
// --- Parchment Background ---
ctx.fillStyle = parchmentColor;
ctx.fillRect(0, 0, baseWidth, baseHeight);
// --- Paper Texture/Noise ---
if (noiseOpacity > 0) {
const noiseCanvasSource = document.createElement('canvas');
noiseCanvasSource.width = baseWidth;
noiseCanvasSource.height = baseHeight;
const noiseCtx = noiseCanvasSource.getContext('2d');
const imageData = noiseCtx.createImageData(baseWidth, baseHeight);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const randVal = Math.random() * 255;
data[i] = randVal; // R
data[i + 1] = randVal; // G
data[i + 2] = randVal; // B
data[i + 3] = 255; // Alpha (fully opaque noise pattern)
}
noiseCtx.putImageData(imageData, 0, 0);
ctx.save();
ctx.globalAlpha = noiseOpacity;
ctx.globalCompositeOperation = 'overlay'; // 'multiply' or 'overlay' are good for texture
ctx.drawImage(noiseCanvasSource, 0, 0);
ctx.restore();
}
// --- Stains ---
if (numStains > 0 && maxStainSize > 0) {
ctx.fillStyle = stainColor;
for (let i = 0; i < numStains; i++) {
const x = Math.random() * baseWidth;
const y = Math.random() * baseHeight;
const radiusX = (Math.random() * 0.7 + 0.3) * maxStainSize / 2;
const radiusY = (Math.random() * 0.7 + 0.3) * maxStainSize / 2;
const rotation = Math.random() * Math.PI * 2;
ctx.beginPath();
ctx.ellipse(x, y, radiusX, radiusY, rotation, 0, Math.PI * 2);
ctx.fill();
}
}
// --- Central Binding Crease ---
ctx.save();
// Crease shadow (left side of center)
ctx.beginPath();
ctx.moveTo(baseWidth / 2 - 2, 0);
ctx.lineTo(baseWidth / 2 - 2, baseHeight);
ctx.strokeStyle = "rgba(0,0,0,0.12)";
ctx.lineWidth = 4;
ctx.stroke();
// Crease highlight (right side of center)
ctx.beginPath();
ctx.moveTo(baseWidth / 2 + 1, 0);
ctx.lineTo(baseWidth / 2 + 1, baseHeight);
ctx.strokeStyle = "rgba(255,255,255,0.08)";
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
// --- Main Image ---
const imgMargin = Math.min(baseWidth, baseHeight) * 0.15;
const imgAvailableWidth = baseWidth - 2 * imgMargin;
const imgAvailableHeight = baseHeight - 2 * imgMargin;
if (imgAvailableWidth > 0 && imgAvailableHeight > 0 && originalImg.naturalWidth > 0 && originalImg.naturalHeight > 0) {
ctx.save();
if (imageFilter) ctx.filter = imageFilter;
ctx.globalAlpha = imageOpacity;
const sWidth = originalImg.naturalWidth;
const sHeight = originalImg.naturalHeight;
const hRatio = imgAvailableWidth / sWidth;
const vRatio = imgAvailableHeight / sHeight;
const ratio = Math.min(hRatio, vRatio);
const finalImgWidth = sWidth * ratio;
const finalImgHeight = sHeight * ratio;
const drawX = imgMargin + (imgAvailableWidth - finalImgWidth) / 2;
const drawY = imgMargin + (imgAvailableHeight - finalImgHeight) / 2;
ctx.drawImage(originalImg, 0, 0, sWidth, sHeight, drawX, drawY, finalImgWidth, finalImgHeight);
ctx.restore();
}
// --- Text ---
const textBlockY = imgMargin + imgAvailableHeight + imgMargin*0.5; // Start text below image
const textMarginHorizontal = imgMargin * 0.8; // Horizontal margin for text
const textBlockWidth = baseWidth - 2 * textMarginHorizontal;
const textBlockMaxHeight = baseHeight - textBlockY - imgMargin*0.5; // Remaining height for text
const lineHeight = parsedTextSize * 1.4;
if (textContent && textSize > 0 && textBlockWidth > 0 && textBlockMaxHeight > lineHeight) {
ctx.font = currentFontSetting;
ctx.fillStyle = textColor;
ctx.textAlign = "left";
ctx.textBaseline = "top";
wrapText(ctx, textContent, textMarginHorizontal, textBlockY, textBlockWidth, lineHeight, textBlockMaxHeight);
}
function wrapText(context, text, x, y, maxWidth, lineH, maxTextHeight) {
const words = text.split(' ');
let line = '';
let currentY = y;
for (let n = 0; n < words.length; n++) {
if (currentY + lineH > y + maxTextHeight && maxTextHeight > 0) break;
const testLine = line + words[n] + ' ';
const metrics = context.measureText(testLine);
const testWidth = metrics.width;
if (testWidth > maxWidth && n > 0) {
context.fillText(line, x, currentY);
line = words[n] + ' ';
currentY += lineH;
} else {
line = testLine;
}
}
if (currentY + lineH <= y + maxTextHeight || maxTextHeight <= 0) {
context.fillText(line, x, currentY);
}
}
// --- Final Assembly Canvas (for clipping and final effects) ---
const finalCanvas = document.createElement('canvas');
finalCanvas.width = contentCanvas.width; // Padded size
finalCanvas.height = contentCanvas.height;
const finalCtx = finalCanvas.getContext('2d');
// Create irregular path for the "torn" paper shape
const borderPath = new Path2D();
const makePoint = (baseX, baseY, maxOffset) => ({
x: baseX + (Math.random() - 0.5) * 2 * maxOffset,
y: baseY + (Math.random() - 0.5) * 2 * maxOffset
});
// Path starts at top-left content corner, offset by padding/2
let currentPathX = padding / 2 + (Math.random() - 0.5) * edgeIrregularity;
let currentPathY = padding / 2 + (Math.random() - 0.5) * edgeIrregularity;
borderPath.moveTo(currentPathX, currentPathY);
const numSegments = 15;
// Top edge
for (let i = 1; i <= numSegments; i++) { let pt = makePoint(padding/2 + (baseWidth / numSegments) * i, padding/2, edgeIrregularity); borderPath.lineTo(pt.x, pt.y); }
// Right edge
for (let i = 1; i <= numSegments; i++) { let pt = makePoint(padding/2 + baseWidth, padding/2 + (baseHeight / numSegments) * i, edgeIrregularity); borderPath.lineTo(pt.x, pt.y); }
// Bottom edge
for (let i = 1; i <= numSegments; i++) { let pt = makePoint(padding/2 + baseWidth - (baseWidth / numSegments) * i, padding/2 + baseHeight, edgeIrregularity); borderPath.lineTo(pt.x, pt.y); }
// Left edge
for (let i = 1; i <= numSegments; i++) { let pt = makePoint(padding/2, padding/2 + baseHeight - (baseHeight / numSegments) * i, edgeIrregularity); borderPath.lineTo(pt.x, pt.y); }
borderPath.closePath();
finalCtx.clip(borderPath);
finalCtx.drawImage(contentCanvas, 0, 0); // Draw all content into the clipped area
// --- Edge Darkening (applied to the edges of the clipped area) ---
if (edgeDarkeningFactor > 0 && edgeIrregularity > 0) {
finalCtx.save();
finalCtx.strokeStyle = `rgba(60, 40, 20, ${edgeDarkeningFactor})`;
finalCtx.lineWidth = edgeIrregularity * 1.5;
finalCtx.lineJoin = 'round';
finalCtx.lineCap = 'round';
finalCtx.shadowColor = `rgba(30, 20, 10, ${edgeDarkeningFactor * 1.2})`;
finalCtx.shadowBlur = edgeIrregularity * 0.75;
finalCtx.stroke(borderPath); // Stroke the same path used for clipping
finalCtx.restore();
}
// --- Vignette (applied over everything within the clip) ---
if (vignetteStrength > 0) {
finalCtx.save();
finalCtx.globalCompositeOperation = 'source-atop'; // Apply only on existing pixels
const centerX = finalCanvas.width / 2;
const centerY = finalCanvas.height / 2;
const outerRadius = Math.max(finalCanvas.width, finalCanvas.height) * 0.75;
const innerRadius = outerRadius * Math.max(0, (1 - vignetteStrength));
const gradient = finalCtx.createRadialGradient(centerX, centerY, innerRadius, centerX, centerY, outerRadius);
gradient.addColorStop(0, 'rgba(0,0,0,0)');
gradient.addColorStop(1, vignetteColor);
finalCtx.fillStyle = gradient;
finalCtx.fillRect(0, 0, finalCanvas.width, finalCanvas.height);
finalCtx.restore();
}
return finalCanvas;
}
Apply Changes