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.
| Service | Responsibility |
|---|---|
| ImageAssetService | Loads and validates uploaded images |
| CanvasRenderService | Renders the collage onto the HTML Canvas |
| ProjectStorageService | Persists project data |
| GridLayoutEngine | Generates grid layouts |
| LongImageLayoutEngine | Generates 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:
| Mode | Behavior |
|---|---|
| Cover | Fills the frame completely and may crop the image. |
| Contain | Displays the entire image while preserving its aspect ratio. Empty space may remain. |
| Stretch | Fills 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
evalin 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:
Upload images.
Generate a layout.
Render the preview.
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.
Jasen FiciPosted Aug 10, 2026, 1:28 PM
We featured this in our DotNetNews issue here: https://dotnetnews.co/archive/the-net-news-daily-issue-515/