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:

  • Reading multiple large image files without freezing the interface

  • Validating image formats and dimensions

  • Generating grid, magazine, and long-image layouts

  • Cropping images without distorting them

  • Supporting drag, resize, reorder, and zoom interactions

  • Rendering sharply on high-density displays

  • Exporting PNG and JPEG files

  • Preserving project state between browser sessions

  • Releasing object URLs and image memory correctly

  • Keeping private photos inside the browser

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:

  • Project state

  • Layout rules

  • Image metadata

  • User controls

  • Undo and redo history

  • Export settings

  • Local persistence

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

  • Drawing images

  • Cropping

  • Scaling

  • Clipping

  • Applying transforms

  • Exporting image files

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.

  • C# manages project state and layout calculations.

  • JavaScript decodes images, renders the HTML Canvas, and exports the final image.

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:

  • Rendered at different resolutions

  • Saved to IndexedDB

  • Restored later

  • Exported as JSON

  • Used by another rendering engine

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:

  • Lower memory usage

  • Faster loading

  • No unnecessary duplication of image data

  • Better performance for large images

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:

  • Every render would decode the image again.

  • CPU usage would increase significantly.

  • Scrolling and dragging would become noticeably slower.

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:

  • File size

  • Pixel count

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:

  • Revokes every object URL.

  • Clears browser references.

  • Disposes the imported JavaScript module.

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:

  • Maximum number of uploaded images

  • Maximum file size per image

  • Total project memory usage

  • Maximum export resolution

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:

  • Easier unit testing

  • Simpler maintenance

  • Reusable layout algorithms

  • Independent rendering implementations

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:

  • Screenshot collections

  • Product descriptions

  • Tutorials

  • Chat conversations

  • Step-by-step guides

  • Mobile-friendly social media content

Horizontal stitching works well for:

  • Panoramic image comparisons

  • Before-and-after examples

  • Storyboards

  • Timeline presentations

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:

  • Preview scale

  • Export scale

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:

  • Beginning a drag operation

  • Applying a new layout

  • Deleting an item

  • Changing the canvas size

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:

  • Project JSON

  • Source image blobs

  • Generated thumbnails

  • Autosave records

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:

  • Unreleased object URLs

  • Repeated image decoding

  • Very large canvas elements

  • Keeping deleted assets in memory

  • Creating Base64 copies

  • Saving too many undo snapshots

  • Rendering full resolution during every interaction

Adopt the following practices:

  • Revoke object URLs when assets are removed.

  • Clear image caches when projects are closed.

  • Limit image dimensions.

  • Limit the total number of project pixels.

  • Render thumbnails in the asset panel.

  • Store only project metadata in history snapshots.

  • Use a lower-resolution preview canvas.

  • Render at full resolution only during export.

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:

  • The interactive preview continues using a standard HTML Canvas.

  • High-resolution exports are rendered inside the worker.

  • The UI remains responsive while export processing occurs in the background.

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:

  • Maximum canvas width

  • Maximum canvas height

  • Maximum total pixel count

  • Estimated memory usage

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:

  • Render the image in tiles.

  • Reduce the export width.

  • Limit the maximum export resolution.

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:

  • Export several numbered image files.

  • Render individual tiles inside a worker.

  • Restrict the maximum long-image height.

  • Automatically scale the project when necessary.

  • Fall back to server-side rendering only after explicit user consent.

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:

  • Headings

  • Captions

  • Dates

  • Product labels

  • Watermarks

  • Step numbers

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:

  • A list of all layers

  • Buttons for moving layers up and down

  • Numeric X and Y position controls

  • Width and height editors

  • Delete controls

  • Alt-text fields for uploaded images

  • Keyboard shortcuts

  • Clearly visible focus indicators

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:

  • Remote analytics

  • Cloud-hosted fonts

  • Error-reporting SDKs

  • Third-party image processing services

  • Server-generated previews

  • Remote background-removal APIs

Review every network request made by the application.

A privacy-focused implementation should:

  • Process source images locally whenever possible.

  • Disable upload analytics.

  • Store projects in IndexedDB.

  • Use locally hosted fonts.

  • Avoid transmitting image-derived metadata.

  • Clearly disclose optional cloud-based features.

  • Revoke temporary object URLs after use.

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:

  • Percentage of users who upload at least two images

  • Time from upload to first preview

  • Number of layout changes per project

  • Export completion rate

  • Export failure rate

  • Average number of images per project

  • Average export dimensions

  • Memory-related errors

  • Long-image usage

  • Percentage of returning local projects

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

  • Supported MIME types are validated.

  • Empty files are rejected.

  • File-size limits are enforced.

  • Pixel-count limits are enforced.

  • Corrupted images fail gracefully.

Memory Management

  • Object URLs are revoked.

  • Deleted assets are removed from caches.

  • Preview resolution is limited.

  • Export size is validated.

  • History snapshots contain only metadata.

Rendering

  • Cover cropping behaves correctly.

  • Contain mode preserves the complete image.

  • High-DPI rendering remains sharp.

  • Rotation occurs around the item center.

  • Border-radius clipping is correct.

  • Export output matches the preview.

Privacy

  • Images are never uploaded unexpectedly.

  • IndexedDB data can be deleted.

  • Analytics exclude image content.

  • Optional remote processing is clearly disclosed.

  • Results are not automatically published.

User Experience

  • Reordering functions correctly.

  • Undo and redo behave as expected.

  • Export errors are understandable.

  • Keyboard controls are available.

  • Projects recover successfully after refresh.

  • Long-image limitations are clearly explained.

33. Why Local Processing Is a Strong Default

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

  • Extremely large exports

  • Shared cloud projects

  • Multi-user collaboration

  • Server-only rendering requirements

  • Consistent rendering across platforms

However, local processing provides several significant advantages:

  • No upload delay

  • Reduced server costs

  • Better privacy

  • Immediate previews

  • Offline capability

  • Lower storage responsibility

  • Fewer data-retention concerns

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:

  • A well-defined project model

  • Predictable layout calculations

  • Secure local file handling

  • Efficient image decoding

  • Accurate Canvas cropping

  • High-DPI rendering

  • Effective memory management

  • Local project persistence

  • Undo and redo support

  • Accessible editing controls

  • Export-size validation

  • Clearly defined privacy boundaries

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.