Please bookmark this page to avoid losing your image tool!

Image Ancient Spell Book Page Creator

(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,
    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;
}

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 Ancient Spell Book Page Creator allows users to transform any image into an enchanting, vintage-style book page. By applying various effects such as sepia filtering, parchment coloration, and simulated noise, users can create realistic, weathered appearances reminiscent of ancient manuscripts. Additionally, users can add custom text using Medieval-style fonts, which enhances the magical and mysterious theme. This tool is ideal for use in creating themed graphics for fantasy events, book covers, role-playing games, or artistic presentations.

Leave a Reply

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