Introduction

Photo collage applications appear simple from the outside.

A user selects several images, chooses a layout, adjusts the spacing, and downloads the final result.

However, a reliable browser-based editor must solve several technical problems:

This article builds a privacy-first collage editor with Blazor WebAssembly, HTML Canvas, and JavaScript interop.

The core image workflow runs entirely in the browser. Images do not need to be uploaded to an ASP.NET Core server before they can be arranged or exported.

Products in the pic collage category demonstrate the value of a focused editor that lets users combine several photos quickly without opening a large professional design application.

The implementation below is an independent technical example. It does not describe the internal architecture of any existing product.

1. Why Use Blazor WebAssembly?

Blazor WebAssembly allows C# code to run directly inside the browser.

That makes it suitable for managing:

HTML Canvas remains useful for image rendering because the browser already provides highly optimized APIs for:

JavaScript interop connects the Blazor state layer with the Canvas rendering layer.

The architecture looks like this:

Blazor Components
        │
        ▼
C# Project & Layout Models
        │
        ▼
Canvas Rendering Service
        │
        ▼
JavaScript Interop Module
        │
        ▼
HTML Canvas
        │
        ▼
PNG / JPEG Export

All source images remain local to the browser.

2. Create the Blazor WebAssembly Project

Create a standalone Blazor WebAssembly application:

dotnet new blazorwasm -n PrivacyFirstCollage

cd PrivacyFirstCollage

A possible project structure is:

PrivacyFirstCollage
├── Components
├── Models
├── Services
├── Layouts
├── Pages
├── wwwroot
│   └── js
│       └── collageCanvas.js
└── Program.cs

The application contains two primary layers.

3. Define the Project Model

Create Models/CollageProject.cs

namespace PrivacyFirstCollage.Models;

public sealed class CollageProject
{
    public Guid Id { get; init; }
        = Guid.NewGuid();

    public string Name { get; set; }
        = "Untitled Collage";

    public int CanvasWidth { get; set; }
        = 1200;

    public int CanvasHeight { get; set; }
        = 1200;

    public string BackgroundColor { get; set; }
        = "#ffffff";

    public double Gap { get; set; }
        = 12;

    public double OuterPadding { get; set; }
        = 12;

    public LayoutType Layout { get; set; }
        = LayoutType.Grid;

    public List<CollageItem> Items { get; set; }
        = new();

    public DateTimeOffset UpdatedAt { get; set; }
        = DateTimeOffset.UtcNow;
}

Create Models/LayoutType.cs

namespace PrivacyFirstCollage.Models;

public enum LayoutType
{
    Grid,
    Freeform,
    VerticalLongImage,
    HorizontalLongImage
}

The project stores logical values rather than Canvas-specific objects.

This separation allows the same project to be:

4. Define the Image Item Model

Create Models/CollageItem.cs

namespace PrivacyFirstCollage.Models;

public sealed class CollageItem
{
    public Guid Id { get; init; }
        = Guid.NewGuid();

    public required Guid AssetId { get; init; }

    public double X { get; set; }

    public double Y { get; set; }

    public double Width { get; set; }

    public double Height { get; set; }

    public double RotationDegrees { get; set; }

    public double Scale { get; set; }
        = 1;

    public double OffsetX { get; set; }

    public double OffsetY { get; set; }

    public double Opacity { get; set; }
        = 1;

    public double BorderRadius { get; set; }

    public int ZIndex { get; set; }

    public ImageFitMode FitMode { get; set; }
        = ImageFitMode.Cover;
}

Create Models/ImageFitMode.cs

namespace PrivacyFirstCollage.Models;

public enum ImageFitMode
{
    Cover,
    Contain,
    Stretch
}

The rectangle properties define the image frame.

The OffsetX, OffsetY, and Scale properties determine how the original image is positioned within that frame.

This distinction is important.

Moving the frame and moving the image inside the frame are two different editing operations.

5. Define the Local Image Asset

Each uploaded image should be represented by a lightweight model that stores its metadata rather than the image data itself.

Create Models/ImageAsset.cs:

namespace PrivacyFirstCollage.Models;

public sealed class ImageAsset
{
    public Guid Id { get; init; }
        = Guid.NewGuid();

    public required string FileName { get; init; }

    public required string ContentType { get; init; }

    public required string ObjectUrl { get; init; }

    public required int NaturalWidth { get; init; }

    public required int NaturalHeight { get; init; }

    public long FileSize { get; init; }
}

The ObjectUrl contains a temporary browser URL created with URL.createObjectURL().

Using an object URL instead of converting the image into Base64 provides several advantages:

6. Register the Application Services

Update Program.cs:

using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using PrivacyFirstCollage;
using PrivacyFirstCollage.Layouts;
using PrivacyFirstCollage.Services;

var builder =
    WebAssemblyHostBuilder.CreateDefault(args);

builder.RootComponents.Add<App>("#app");

builder.RootComponents.Add<HeadOutlet>(
    "head::after");

builder.Services.AddScoped<ImageAssetService>();
builder.Services.AddScoped<CanvasRenderService>();
builder.Services.AddScoped<ProjectStorageService>();

builder.Services.AddScoped<GridLayoutEngine>();
builder.Services.AddScoped<LongImageLayoutEngine>();

await builder.Build().RunAsync();

The application is composed of several focused services.

ServiceResponsibility
ImageAssetServiceLoads and validates uploaded images
CanvasRenderServiceRenders the collage onto the HTML Canvas
ProjectStorageServicePersists project data
GridLayoutEngineGenerates grid layouts
LongImageLayoutEngineGenerates long-image layouts

Keeping these responsibilities separate makes the application easier to test and maintain.

7. Create the JavaScript Canvas Module

Create wwwroot/js/collageCanvas.js:

const imageCache = new Map();

export async function createObjectUrl(
    streamReference,
    contentType) {

    const buffer =
        await streamReference.arrayBuffer();

    const blob =
        new Blob(
            [buffer],
            { type: contentType });

    return URL.createObjectURL(blob);
}

export function revokeObjectUrl(url) {

    URL.revokeObjectURL(url);

    imageCache.delete(url);
}

export async function getImageMetadata(url) {

    const image =
        await loadImage(url);

    return {

        width: image.naturalWidth,

        height: image.naturalHeight

    };

}

async function loadImage(url) {

    if (imageCache.has(url)) {

        return imageCache.get(url);

    }

    const image =
        new Image();

    image.decoding = "async";

    const loaded =
        new Promise((resolve, reject) => {

            image.onload = () => resolve(image);

            image.onerror = reject;

        });

    image.src = url;

    await loaded;

    imageCache.set(url, image);

    return image;

}

Why Cache Images?

The module maintains an in-memory cache of decoded images.

Without caching:

By caching decoded image elements, the browser only performs image decoding once.

8. Create the Image Asset Service

Create Services/ImageAssetService.cs:

using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
using PrivacyFirstCollage.Models;

namespace PrivacyFirstCollage.Services;

public sealed class ImageAssetService
    : IAsyncDisposable
{
    private const long MaxFileSize =
        20 * 1024 * 1024;

    private static readonly HashSet<string>
        AllowedContentTypes =
            new(StringComparer.OrdinalIgnoreCase)
            {
                "image/jpeg",
                "image/png",
                "image/webp"
            };

    private readonly IJSRuntime _jsRuntime;

    private IJSObjectReference? _module;

    private readonly List<string>
        _objectUrls = new();

    public ImageAssetService(
        IJSRuntime jsRuntime)
    {
        _jsRuntime = jsRuntime;
    }

    public async Task<ImageAsset> CreateAsync(
        IBrowserFile file)
    {
        if (file.Size <= 0)
        {
            throw new InvalidDataException(
                "The selected image is empty.");
        }

        if (file.Size > MaxFileSize)
        {
            throw new InvalidDataException(
                "The image exceeds the 20 MB limit.");
        }

        if (!AllowedContentTypes.Contains(
            file.ContentType))
        {
            throw new InvalidDataException(
                "Only JPEG, PNG, and WebP files are supported.");
        }

        var module =
            await GetModuleAsync();

        await using var stream =
            file.OpenReadStream(MaxFileSize);

        using var streamReference =
            new DotNetStreamReference(stream);

        var objectUrl =
            await module.InvokeAsync<string>(
                "createObjectUrl",
                streamReference,
                file.ContentType);

        var metadata =
            await module.InvokeAsync<ImageMetadata>(
                "getImageMetadata",
                objectUrl);

        ValidateDimensions(
            metadata.Width,
            metadata.Height);

        _objectUrls.Add(objectUrl);

        return new ImageAsset
        {
            FileName = file.Name,
            ContentType = file.ContentType,
            ObjectUrl = objectUrl,
            NaturalWidth = metadata.Width,
            NaturalHeight = metadata.Height,
            FileSize = file.Size
        };
    }

    private static void ValidateDimensions(
        int width,
        int height)
    {
        if (width <= 0 || height <= 0)
        {
            throw new InvalidDataException(
                "The image dimensions are invalid.");
        }

        const long maxPixels =
            40_000_000;

        var pixelCount =
            (long)width * height;

        if (pixelCount > maxPixels)
        {
            throw new InvalidDataException(
                "The image dimensions are too large.");
        }
    }

    private async Task<IJSObjectReference>
        GetModuleAsync()
    {
        _module ??=
            await _jsRuntime.InvokeAsync<
                IJSObjectReference>(
                "import",
                "./js/collageCanvas.js");

        return _module;
    }

    public async ValueTask DisposeAsync()
    {
        if (_module is null)
        {
            return;
        }

        foreach (var url in _objectUrls)
        {
            await _module.InvokeVoidAsync(
                "revokeObjectUrl",
                url);
        }

        await _module.DisposeAsync();
    }

    private sealed record ImageMetadata(
        int Width,
        int Height);
}

Why Validate More Than File Size?

Checking only the uploaded file size is not sufficient.

A highly compressed image can occupy only a few megabytes on disk while expanding into hundreds of megabytes after decoding.

The service therefore validates both:

This helps protect the application from excessive memory consumption and potential browser crashes.

Resource Cleanup

The service implements IAsyncDisposable to ensure all temporary browser resources are released.

During disposal it:

Proper cleanup is essential for long-running browser applications that may load hundreds of images during a single editing session.

9. Add an Image Upload Component

The collage editor needs a component that accepts multiple images, validates them, and creates ImageAsset objects for each uploaded file.

Create Components/ImageUploader.razor:

@using Microsoft.AspNetCore.Components.Forms
@using PrivacyFirstCollage.Models

@inject ImageAssetService AssetService

<InputFile
    OnChange="HandleFilesAsync"
    multiple
    accept="image/jpeg,image/png,image/webp" />

@if (!string.IsNullOrWhiteSpace(_error))
{
    <p class="upload-error">@_error</p>
}

@code {

    [Parameter]
    public EventCallback<IReadOnlyList<ImageAsset>>
        AssetsCreated { get; set; }

    private string? _error;

    private async Task HandleFilesAsync(
        InputFileChangeEventArgs eventArgs)
    {
        _error = null;

        var created =
            new List<ImageAsset>();

        foreach (var file in
            eventArgs.GetMultipleFiles(20))
        {
            try
            {
                var asset =
                    await AssetService.CreateAsync(file);

                created.Add(asset);
            }
            catch (Exception exception)
            {
                _error =
                    $"{file.Name}: {exception.Message}";
            }
        }

        if (created.Count > 0)
        {
            await AssetsCreated.InvokeAsync(created);
        }
    }

}

The browser can accept several files in a single selection.

A production-quality application should still define reasonable limits for:

Applying these limits early prevents excessive memory consumption and improves the overall user experience.

10. Build a Grid Layout Engine

Rather than positioning images directly during rendering, the application first calculates a logical layout.

Create Layouts/GridLayoutEngine.cs:

using PrivacyFirstCollage.Models;

namespace PrivacyFirstCollage.Layouts;

public sealed class GridLayoutEngine
{
    public IReadOnlyList<CollageItem>
        CreateLayout(
            IReadOnlyList<ImageAsset> assets,
            int canvasWidth,
            int canvasHeight,
            int columns,
            double gap,
            double outerPadding)
    {
        if (assets.Count == 0)
        {
            return Array.Empty<CollageItem>();
        }

        columns =
            Math.Clamp(
                columns,
                1,
                assets.Count);

        var rows =
            (int)Math.Ceiling(
                assets.Count /
                (double)columns);

        var availableWidth =
            canvasWidth
            - (outerPadding * 2)
            - (gap * (columns - 1));

        var availableHeight =
            canvasHeight
            - (outerPadding * 2)
            - (gap * (rows - 1));

        var cellWidth =
            availableWidth / columns;

        var cellHeight =
            availableHeight / rows;

        var items =
            new List<CollageItem>(
                assets.Count);

        for (var index = 0;
             index < assets.Count;
             index++)
        {
            var column =
                index % columns;

            var row =
                index / columns;

            items.Add(
                new CollageItem
                {
                    AssetId =
                        assets[index].Id,

                    X =
                        outerPadding
                        + column * (cellWidth + gap),

                    Y =
                        outerPadding
                        + row * (cellHeight + gap),

                    Width = cellWidth,

                    Height = cellHeight,

                    ZIndex = index,

                    FitMode = ImageFitMode.Cover
                });
        }

        return items;
    }
}

Why Separate Layout from Rendering?

The layout engine performs no Canvas drawing.

Its only responsibility is to calculate the position and size of each collage item.

Separating layout logic from rendering offers several benefits:

Whether the project is rendered on an HTML Canvas, SVG, SkiaSharp, or another graphics engine, the layout calculations remain unchanged.

11. Test the Grid Layout Engine

Because the layout engine contains only business logic, it can be tested with standard unit tests.

Example xUnit test:

using PrivacyFirstCollage.Layouts;
using PrivacyFirstCollage.Models;

namespace PrivacyFirstCollage.Tests;

public sealed class GridLayoutEngineTests
{
    [Fact]
    public void CreatesTwoByTwoGrid()
    {
        var assets =
            Enumerable.Range(0, 4)
            .Select(index =>
                new ImageAsset
                {
                    FileName =
                        $"image-{index}.jpg",

                    ContentType =
                        "image/jpeg",

                    ObjectUrl =
                        $"blob:image-{index}",

                    NaturalWidth = 1200,

                    NaturalHeight = 800
                })
            .ToList();

        var engine =
            new GridLayoutEngine();

        var result =
            engine.CreateLayout(
                assets,
                canvasWidth: 1000,
                canvasHeight: 1000,
                columns: 2,
                gap: 20,
                outerPadding: 20);

        Assert.Equal(
            4,
            result.Count);

        Assert.All(
            result,
            item =>
            {
                Assert.Equal(
                    470,
                    item.Width);

                Assert.Equal(
                    470,
                    item.Height);
            });
    }
}

By testing layout calculations independently, rendering bugs become much easier to isolate.

12. Build a Long-Image Layout Engine

Long-image layouts preserve each source image's aspect ratio while stitching multiple images together.

Create Layouts/LongImageLayoutEngine.cs:

using PrivacyFirstCollage.Models;

namespace PrivacyFirstCollage.Layouts;

public sealed class LongImageLayoutEngine
{
    public LongImageLayoutResult CreateVertical(
        IReadOnlyList<ImageAsset> assets,
        int targetWidth,
        double gap,
        double outerPadding)
    {
        var contentWidth =
            targetWidth
            - (outerPadding * 2);

        var items =
            new List<CollageItem>(
                assets.Count);

        var currentY =
            outerPadding;

        for (var index = 0;
             index < assets.Count;
             index++)
        {
            var asset =
                assets[index];

            var aspectRatio =
                asset.NaturalWidth /
                (double)asset.NaturalHeight;

            var height =
                contentWidth / aspectRatio;

            items.Add(
                new CollageItem
                {
                    AssetId = asset.Id,

                    X = outerPadding,

                    Y = currentY,

                    Width = contentWidth,

                    Height = height,

                    FitMode = ImageFitMode.Contain,

                    ZIndex = index
                });

            currentY +=
                height + gap;
        }

        var canvasHeight =
            assets.Count == 0
                ? (int)(outerPadding * 2)
                : (int)Math.Ceiling(
                    currentY
                    - gap
                    + outerPadding);

        return new LongImageLayoutResult(
            targetWidth,
            canvasHeight,
            items);
    }

    public LongImageLayoutResult CreateHorizontal(
        IReadOnlyList<ImageAsset> assets,
        int targetHeight,
        double gap,
        double outerPadding)
    {
        var contentHeight =
            targetHeight
            - (outerPadding * 2);

        var items =
            new List<CollageItem>(
                assets.Count);

        var currentX =
            outerPadding;

        for (var index = 0;
             index < assets.Count;
             index++)
        {
            var asset =
                assets[index];

            var aspectRatio =
                asset.NaturalWidth /
                (double)asset.NaturalHeight;

            var width =
                contentHeight * aspectRatio;

            items.Add(
                new CollageItem
                {
                    AssetId = asset.Id,

                    X = currentX,

                    Y = outerPadding,

                    Width = width,

                    Height = contentHeight,

                    FitMode = ImageFitMode.Contain,

                    ZIndex = index
                });

            currentX +=
                width + gap;
        }

        var canvasWidth =
            assets.Count == 0
                ? (int)(outerPadding * 2)
                : (int)Math.Ceiling(
                    currentX
                    - gap
                    + outerPadding);

        return new LongImageLayoutResult(
            canvasWidth,
            targetHeight,
            items);
    }
}

public sealed record LongImageLayoutResult(
    int CanvasWidth,
    int CanvasHeight,
    IReadOnlyList<CollageItem> Items);

Vertical stitching is particularly useful for:

Horizontal stitching works well for:

Why Preserve Aspect Ratio?

Unlike a traditional collage, long-image layouts are typically intended to present content sequentially.

Stretching images would distort screenshots and photographs.

Using the Contain fit mode ensures every source image remains proportional while allowing the canvas dimensions to expand dynamically.

13. Render the Collage with HTML Canvas

With the layout engine responsible for calculating positions and sizes, the next step is rendering the collage onto an HTML Canvas.

Extend wwwroot/js/collageCanvas.js with the following function:

export async function renderCollage(
    canvas,
    project,
    assets,
    devicePixelRatio) {

    const ratio =
        Math.max(
            1,
            devicePixelRatio || 1);

    canvas.width =
        Math.round(
            project.canvasWidth * ratio);

    canvas.height =
        Math.round(
            project.canvasHeight * ratio);

    canvas.style.width =
        `${project.canvasWidth}px`;

    canvas.style.height =
        `${project.canvasHeight}px`;

    const context =
        canvas.getContext("2d");

    context.setTransform(
        ratio,
        0,
        0,
        ratio,
        0,
        0);

    context.clearRect(
        0,
        0,
        project.canvasWidth,
        project.canvasHeight);

    context.fillStyle =
        project.backgroundColor;

    context.fillRect(
        0,
        0,
        project.canvasWidth,
        project.canvasHeight);

    const assetMap =
        new Map(
            assets.map(asset => [
                asset.id,
                asset
            ]));

    const orderedItems =
        [...project.items]
            .sort(
                (first, second) =>
                    first.zIndex - second.zIndex);

    for (const item of orderedItems) {

        const asset =
            assetMap.get(item.assetId);

        if (!asset) {
            continue;
        }

        const image =
            await loadImage(
                asset.objectUrl);

        drawItem(
            context,
            image,
            item);
    }
}

function drawItem(
    context,
    image,
    item) {

    context.save();

    context.globalAlpha =
        Math.max(
            0,
            Math.min(
                1,
                item.opacity));

    const centerX =
        item.x + item.width / 2;

    const centerY =
        item.y + item.height / 2;

    context.translate(
        centerX,
        centerY);

    context.rotate(
        item.rotationDegrees *
        Math.PI /
        180);

    context.translate(
        -centerX,
        -centerY);

    createRoundedRectanglePath(
        context,
        item.x,
        item.y,
        item.width,
        item.height,
        item.borderRadius);

    context.clip();

    drawImageWithFit(
        context,
        image,
        item);

    context.restore();
}

function createRoundedRectanglePath(
    context,
    x,
    y,
    width,
    height,
    radius) {

    const safeRadius =
        Math.max(
            0,
            Math.min(
                radius,
                width / 2,
                height / 2));

    context.beginPath();

    context.roundRect(
        x,
        y,
        width,
        height,
        safeRadius);
}

High-DPI Rendering

Modern displays often have a device pixel ratio greater than 1.

Without accounting for window.devicePixelRatio, the canvas preview appears blurry on Retina and other high-density displays.

Scaling the internal canvas resolution while preserving its CSS dimensions ensures crisp rendering across devices.

14. Implement Cover, Contain, and Stretch

Different collage layouts require different image fitting behaviors.

Add the following function to collageCanvas.js:

function drawImageWithFit(
    context,
    image,
    item) {

    if (item.fitMode === 2) {

        context.drawImage(
            image,
            item.x,
            item.y,
            item.width,
            item.height);

        return;
    }

    const imageRatio =
        image.naturalWidth /
        image.naturalHeight;

    const frameRatio =
        item.width /
        item.height;

    let drawWidth;
    let drawHeight;

    if (item.fitMode === 1) {

        if (imageRatio > frameRatio) {

            drawWidth =
                item.width *
                item.scale;

            drawHeight =
                drawWidth /
                imageRatio;

        } else {

            drawHeight =
                item.height *
                item.scale;

            drawWidth =
                drawHeight *
                imageRatio;

        }

    } else {

        if (imageRatio > frameRatio) {

            drawHeight =
                item.height *
                item.scale;

            drawWidth =
                drawHeight *
                imageRatio;

        } else {

            drawWidth =
                item.width *
                item.scale;

            drawHeight =
                drawWidth *
                imageRatio;

        }
    }

    const drawX =
        item.x +
        (item.width - drawWidth) / 2 +
        item.offsetX;

    const drawY =
        item.y +
        (item.height - drawHeight) / 2 +
        item.offsetY;

    context.drawImage(
        image,
        drawX,
        drawY,
        drawWidth,
        drawHeight);
}

Understanding Image Fit Modes

The editor supports three rendering modes:

ModeBehavior
CoverFills the frame completely and may crop the image.
ContainDisplays the entire image while preserving its aspect ratio. Empty space may remain.
StretchFills the frame exactly but may distort the image.

Using multiple fit modes gives users greater flexibility depending on the type of collage they are creating.

15. Create the Canvas Render Service

Create Services/CanvasRenderService.cs:

using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using PrivacyFirstCollage.Models;

namespace PrivacyFirstCollage.Services;

public sealed class CanvasRenderService
    : IAsyncDisposable
{
    private readonly IJSRuntime _jsRuntime;

    private IJSObjectReference? _module;

    public CanvasRenderService(
        IJSRuntime jsRuntime)
    {
        _jsRuntime = jsRuntime;
    }

    public async Task RenderAsync(
        ElementReference canvas,
        CollageProject project,
        IReadOnlyList<ImageAsset> assets)
    {
        var module =
            await GetModuleAsync();

        var devicePixelRatio =
            await _jsRuntime.InvokeAsync<double>(
                "eval",
                "window.devicePixelRatio || 1");

        await module.InvokeVoidAsync(
            "renderCollage",
            canvas,
            project,
            assets,
            devicePixelRatio);
    }

    private async Task<IJSObjectReference>
        GetModuleAsync()
    {
        _module ??=
            await _jsRuntime.InvokeAsync<
                IJSObjectReference>(
                "import",
                "./js/collageCanvas.js");

        return _module;
    }

    public async ValueTask DisposeAsync()
    {
        if (_module is not null)
        {
            await _module.DisposeAsync();
        }
    }
}

Note

Avoid using eval in applications that enforce a strict Content Security Policy (CSP).

A better approach is to expose a dedicated JavaScript function that returns window.devicePixelRatio.

16. Create the Editor Page

Create Pages/Editor.razor:

@page "/editor"

@using PrivacyFirstCollage.Layouts
@using PrivacyFirstCollage.Models
@using PrivacyFirstCollage.Services

@inject GridLayoutEngine GridLayout
@inject CanvasRenderService Renderer

<h1>Collage Editor</h1>

<ImageUploader
    AssetsCreated="AddAssetsAsync" />

<div class="editor-toolbar">

    <label>

        Columns

        <input
            type="number"
            min="1"
            max="8"
            @bind="_columns" />

    </label>

    <label>

        Gap

        <input
            type="range"
            min="0"
            max="80"
            @bind="_project.Gap" />

    </label>

    <button @onclick="ApplyGridAsync">
        Apply Grid
    </button>

    <button @onclick="ExportPngAsync">
        Export PNG
    </button>

</div>

<canvas
    @ref="_canvas"
    class="collage-canvas">
</canvas>

@code {

    private ElementReference _canvas;

    private readonly CollageProject
        _project = new();

    private readonly List<ImageAsset>
        _assets = new();

    private int _columns = 2;

    private async Task AddAssetsAsync(
        IReadOnlyList<ImageAsset> assets)
    {
        _assets.AddRange(assets);

        await ApplyGridAsync();
    }

    private async Task ApplyGridAsync()
    {
        var items =
            GridLayout.CreateLayout(
                _assets,
                _project.CanvasWidth,
                _project.CanvasHeight,
                _columns,
                _project.Gap,
                _project.OuterPadding);

        _project.Items =
            items.ToList();

        _project.UpdatedAt =
            DateTimeOffset.UtcNow;

        await RenderAsync();
    }

    private async Task RenderAsync()
    {
        await Renderer.RenderAsync(
            _canvas,
            _project,
            _assets);
    }

    private async Task ExportPngAsync()
    {
        await Renderer.ExportAsync(
            _canvas,
            "collage.png",
            "image/png",
            1);
    }

}

The editor coordinates the application workflow:

  1. Upload images.

  2. Generate a layout.

  3. Render the preview.

  4. Export the finished collage.

By keeping layout generation, rendering, and file handling in separate services, the page remains focused on orchestration rather than implementation details.

17. Export PNG and JPEG Files

After users finish arranging their collage, they should be able to export it as an image.

Add the following function to wwwroot/js/collageCanvas.js:

export async function exportCanvas(
    canvas,
    fileName,
    contentType,
    quality) {

    const blob =
        await new Promise(
            (resolve, reject) => {

                canvas.toBlob(
                    result => {

                        if (result) {

                            resolve(result);

                        } else {

                            reject(
                                new Error(
                                    "Canvas export failed."));
                        }

                    },
                    contentType,
                    quality);

            });

    const url =
        URL.createObjectURL(blob);

    try {

        const link =
            document.createElement("a");

        link.href = url;

        link.download = fileName;

        document.body.appendChild(link);

        link.click();

        link.remove();

    }
    finally {

        URL.revokeObjectURL(url);

    }

}

Extend CanvasRenderService:

public async Task ExportAsync(
    ElementReference canvas,
    string fileName,
    string contentType,
    double quality)
{
    var module =
        await GetModuleAsync();

    await module.InvokeVoidAsync(
        "exportCanvas",
        canvas,
        fileName,
        contentType,
        quality);
}

PNG is the preferred format when transparency or lossless quality is required.

JPEG generally produces much smaller files for photographic collages.

Example JPEG export:

await Renderer.ExportAsync(
    _canvas,
    "collage.jpg",
    "image/jpeg",
    0.92);

18. Separate Preview Resolution from Export Resolution

Rendering a 6000 × 6000 canvas during every drag or resize operation can significantly reduce responsiveness.

Instead, maintain two rendering scales:

The preview uses a reduced resolution for smooth interaction, while the export renderer generates the final image at full quality.

Project coordinates should always remain based on the logical canvas size.

Example:

public sealed class RenderSettings
{
    public double PreviewScale { get; set; }
        = 0.5;

    public double ExportScale { get; set; }
        = 1;

    public int MaximumExportPixels { get; set; }
        = 60_000_000;
}

Before exporting:

var totalPixels =
    (long)_project.CanvasWidth
    * _project.CanvasHeight;

if (totalPixels > settings.MaximumExportPixels)
{
    throw new InvalidOperationException(
        "The requested export is too large.");
}

Keeping preview rendering lightweight provides a smoother editing experience while still allowing high-resolution exports.

19. Support Reordering

The order of the image assets and the rendering order are related but independent concepts.

For grid layouts, reordering the asset list and regenerating the layout is sufficient.

Example:

private async Task MoveItemAsync(
    int oldIndex,
    int newIndex)
{
    if (oldIndex < 0
        || oldIndex >= _assets.Count
        || newIndex < 0
        || newIndex >= _assets.Count)
    {
        return;
    }

    var asset =
        _assets[oldIndex];

    _assets.RemoveAt(oldIndex);

    _assets.Insert(
        newIndex,
        asset);

    await ApplyGridAsync();
}

For freeform layouts, rendering order is determined by each item's ZIndex.

Separating these concepts makes the editor flexible enough to support multiple layout styles.

20. Implement Undo and Redo

An editor should not duplicate image data for every history entry.

Instead, store only the project state.

Create Services/ProjectHistory.cs:

using System.Text.Json;
using PrivacyFirstCollage.Models;

namespace PrivacyFirstCollage.Services;

public sealed class ProjectHistory
{
    private readonly Stack<string>
        _undo = new();

    private readonly Stack<string>
        _redo = new();

    private readonly JsonSerializerOptions
        _options =
            new(JsonSerializerDefaults.Web);

    public void Push(
        CollageProject project)
    {
        var json =
            JsonSerializer.Serialize(
                project,
                _options);

        _undo.Push(json);

        _redo.Clear();
    }

    public CollageProject? Undo(
        CollageProject current)
    {
        if (_undo.Count == 0)
        {
            return null;
        }

        _redo.Push(
            JsonSerializer.Serialize(
                current,
                _options));

        return Deserialize(
            _undo.Pop());
    }

    public CollageProject? Redo(
        CollageProject current)
    {
        if (_redo.Count == 0)
        {
            return null;
        }

        _undo.Push(
            JsonSerializer.Serialize(
                current,
                _options));

        return Deserialize(
            _redo.Pop());
    }

    private CollageProject? Deserialize(
        string json)
    {
        return JsonSerializer.Deserialize<
            CollageProject>(
            json,
            _options);
    }
}

Record history only at meaningful interaction boundaries, such as:

Avoid creating a history entry for every pointer movement.

21. Persist Projects Locally

localStorage is not well suited for large collage projects because it is synchronous and typically provides limited storage.

IndexedDB is a better choice for storing:

A production implementation may organize the data as follows:

projects

projectId
projectJson
updatedAt

assets

assetId
projectId
blob
fileName
contentType
width
height

When reopening a project, recreate object URLs from the stored image blobs.

Object URLs themselves should never be persisted because they are valid only for the current browser session.

22. Avoid Memory Leaks

Browser-based image editing can consume significant memory over time.

Common causes include:

Adopt the following practices:

23. Use createImageBitmap() for Large Projects

Many browsers decode images more efficiently using createImageBitmap().

A production renderer can replace Image elements with ImageBitmap objects.

Example:

async function loadBitmap(url) {

    const response =
        await fetch(url);

    const blob =
        await response.blob();

    return await createImageBitmap(
        blob,
        {
            imageOrientation: "from-image"
        });
}

ImageBitmap objects also support explicit cleanup:

bitmap.close();

Before adopting this approach, evaluate browser compatibility and memory behavior across your supported platforms.

24. Use OffscreenCanvas for Heavy Rendering

Large collages containing dozens of high-resolution images can place significant load on the browser's main thread.

Where supported, OffscreenCanvas allows rendering to occur inside a Web Worker.

A worker-based architecture can resemble the following:

Blazor UI
      │
      ▼
Project JSON
      │
      ▼
JavaScript Worker
      │
      ▼
OffscreenCanvas
      │
      ▼
Rendered Blob
      │
      ▼
Download

In this architecture:

25. Handle Browser Canvas Limits

Every browser imposes implementation-specific limits on the maximum size of an HTML Canvas.

A very large collage or an extremely tall long-image export may fail even when sufficient system memory appears to be available.

Before rendering, validate the following:

RGBA canvas memory usage can be estimated as:

width × height × 4 bytes

For example, a 10,000 × 30,000 canvas requires approximately:

10,000 × 30,000 × 4
= 1,200,000,000 bytes

This is approximately 1.2 GB of memory before accounting for temporary rendering buffers and intermediate allocations.

For extremely large exports, consider one of the following approaches:

Performing these checks before rendering prevents failed exports and improves the user experience.

26. Render Long Images in Tiles

Instead of creating a single extremely tall canvas, divide the output into multiple sections.

Conceptually:

Output Section 1

Output Section 2

Output Section 3

Output Section 4

Possible implementation strategies include:

For a privacy-first application, clearly explain whenever server-side rendering requires uploading user images.

Users should always understand when their content leaves the local device.

27. Support Text Layers

A collage editor frequently needs to support additional content beyond images, such as:

Create Models/TextItem.cs:

namespace PrivacyFirstCollage.Models;

public sealed class TextItem
{
    public Guid Id { get; init; }
        = Guid.NewGuid();

    public string Text { get; set; }
        = "Text";

    public double X { get; set; }

    public double Y { get; set; }

    public double MaxWidth { get; set; }
        = 400;

    public string FontFamily { get; set; }
        = "Arial";

    public double FontSize { get; set; }
        = 48;

    public string FontWeight { get; set; }
        = "400";

    public string Color { get; set; }
        = "#000000";

    public string TextAlign { get; set; }
        = "left";

    public double RotationDegrees { get; set; }

    public int ZIndex { get; set; }
}

When exporting text, ensure all custom fonts have finished loading.

Wait for the browser's font loading process before rendering:

await document.fonts.ready;

Otherwise, the downloaded image may use fallback fonts that differ from those shown in the preview.

28. Make the Editor Keyboard Accessible

Canvas content is not inherently accessible to keyboard or screen-reader users.

To provide an inclusive editing experience, maintain a semantic interface alongside the canvas.

For example, provide:

The HTML Canvas should never be the sole mechanism for interacting with project content.

29. Keep Privacy Claims Accurate

A local image editor should not claim that "no data ever leaves your device" if the application uses services such as:

Review every network request made by the application.

A privacy-focused implementation should:

Privacy statements should accurately reflect the application's behavior.

30. Add a Content Security Policy

A strict Content Security Policy (CSP) reduces the risk of malicious scripts accessing local project data.

A reasonable starting point is:

default-src 'self';

script-src 'self';

style-src 'self' 'unsafe-inline';

img-src 'self' blob: data:;

connect-src 'self';

font-src 'self';

worker-src 'self' blob:;

The final policy depends on your deployment requirements.

Avoid allowing arbitrary third-party scripts on pages that process private user images.

31. Useful Product Metrics

The success of a collage editor should not be measured solely by page views.

Useful product metrics include:

Analytics should never include uploaded images or rendered collages unless users have explicitly opted in.

32. Production Checklist

Before releasing the editor, verify the following.

File Handling

Memory Management

Rendering

Privacy

User Experience

33. Why Local Processing Is a Strong Default

Server-side image processing remains valuable in scenarios such as:

However, local processing provides several significant advantages:

For standard grid collages and long-image workflows, modern browsers already provide the majority of the required image-processing capabilities.

Conclusion

A reliable browser-based collage editor is far more than a collection of image upload controls.

A production-ready implementation requires:

Blazor WebAssembly provides an excellent foundation for managing project state, layout logic, editing controls, validation, and persistence.

HTML Canvas supplies the low-level rendering capabilities required for drawing, transforming, and exporting images.

JavaScript interop bridges these technologies without requiring the entire application to be written in JavaScript.

Perhaps the most important architectural principle is separating logical project state from rendered pixels.

The project model should describe what the collage contains, while the rendering engine determines how that model is transformed into an image.

Maintaining this separation results in an application that is easier to test, extend, optimize, and persist while remaining responsive and privacy-conscious.

Summary

In this article, we built a privacy-first photo collage editor using Blazor WebAssembly, HTML Canvas, and JavaScript interop. We designed a clear project model, implemented grid and long-image layout engines, rendered high-quality previews with Canvas, supported image uploads, exporting, undo and redo, local persistence, and memory management, while addressing performance, accessibility, and privacy considerations. By separating layout logic from rendering and keeping image processing within the browser whenever possible, the application becomes easier to maintain, more responsive, and better suited for modern privacy-focused web experiences.