How to Build a Simple BPG Image ViewerBPG (Better Portable Graphics) is an image format designed to offer higher compression and better quality than JPEG, using the HEVC (H.265) codec for image data. Although BPG isn’t as widely supported as JPEG or PNG, building a simple BPG image viewer is a great project to learn about binary formats, WebAssembly, codec integration, and lightweight UI design. This guide walks you step-by-step through creating a minimal, cross-platform BPG viewer for the web using JavaScript and WebAssembly, plus a short section about a desktop version using Electron.
What you’ll learn
- Basics of the BPG file structure and decoding workflow
- How to use a WebAssembly BPG decoder in the browser
- Rendering decoded frames onto an HTML5 canvas
- Handling metadata (EXIF, color profiles) and image scaling
- Adding minimal UI: open file, drag-and-drop, zoom, and fit-to-screen
- Optional: packaging as a desktop app with Electron
Prerequisites
- Basic knowledge of HTML, CSS, and JavaScript
- Familiarity with Node.js and npm for building tools and optional Electron packaging
- A code editor and a modern browser (Chrome, Firefox, Edge) that supports WebAssembly
1) Quick overview of the BPG format
BPG uses HEVC to encode image data and places that compressed bitstream inside a simple container that can include metadata such as ICC profiles and EXIF. Decoding requires an HEVC decoder adapted for still images; the most common approach for browser projects is to compile the reference BPG decoder (written in C) to WebAssembly, exposing a small API to JavaScript.
Key points:
- BPG stores HEVC bitstreams inside a compact container.
- Decoding needs a HEVC-capable decoder (or the reference decoder compiled to WebAssembly).
- Color profile and alpha channels may be present and should be handled for correct rendering.
2) Project structure
Create a project folder with this minimal structure:
- index.html
- style.css
- app.js
- bpgdec.wasm (BPG WebAssembly decoder)
- bpgdec.js (loader/bootstrap for wasm)
- sample.bpg (optional test image)
3) Getting a WebAssembly BPG decoder
The original BPG project provides a C decoder (bpgdec). Many projects have compiled this to WebAssembly — you can either build it yourself (recommended if you want control) or use an existing build.
To compile yourself:
- Clone the BPG repository containing the decoder (search “BPG image format bpg.c” for source).
- Install Emscripten and configure it.
- Compile the decoder into a minimal wasm module that exposes two functions: one to decode a buffer and return raw RGBA pixels, and one to query width/height.
Emscripten build flags (example):
emcc bpgdec.c -O3 -s WASM=1 -s EXPORTED_FUNCTIONS="['_bpg_decode', '_bpg_get_width', '_bpg_get_height']" -o bpgdec.js
You’ll get bpgdec.js (JS glue) and bpgdec.wasm.
If you prefer not to compile, find a prebuilt bpgdec.wasm/bpgdec.js bundle from a trusted repository.
4) Loading the wasm decoder in the browser
In index.html include the wasm loader and your app script:
<!doctype html> <html> <head> <meta charset="utf-8" /> <title>Simple BPG Image Viewer</title> <link rel="stylesheet" href="style.css" /> </head> <body> <div id="controls"> <input type="file" id="fileInput" accept=".bpg" /> <button id="zoomIn">Zoom +</button> <button id="zoomOut">Zoom −</button> <button id="fit">Fit</button> </div> <canvas id="canvas"></canvas> <script src="bpgdec.js"></script> <script src="app.js"></script> </body> </html>
bpgdec.js (Emscripten output) will instantiate the wasm module and provide access to exported functions. In app.js wait for the module to be ready, then call exported functions to decode.
5) Reading a .bpg file from user input
Use FileReader to read the file as an ArrayBuffer, then copy that data into the wasm module’s memory and call the decoder.
Core steps in app.js:
- Listen to file input change or drag-and-drop.
- Read file to ArrayBuffer.
- Allocate wasm memory, copy the buffer, call decode.
- Retrieve width/height and an RGBA pixel pointer.
- Create an ImageData and put it on the canvas.
Example code (abridged):
// Wait for wasm module (Emscripten) to be ready as Module document.getElementById('fileInput').addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; const arrayBuffer = await file.arrayBuffer(); const bytes = new Uint8Array(arrayBuffer); const ptr = Module._malloc(bytes.length); Module.HEAPU8.set(bytes, ptr); // call decoder (assumes bpg_decode(ptr, len) returns 0 on success and sets global output pointer) const res = Module._bpg_decode(ptr, bytes.length); if (res !== 0) { console.error('Decode failed'); Module._free(ptr); return; } const width = Module._bpg_get_width(); const height = Module._bpg_get_height(); const pixelPtr = Module._bpg_get_rgba_ptr(); // must be exported/implemented const imgSize = width * height * 4; const rgba = new Uint8ClampedArray(Module.HEAPU8.buffer, pixelPtr, imgSize); const imageData = new ImageData(new Uint8ClampedArray(rgba), width, height); const canvas = document.getElementById('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.putImageData(imageData, 0, 0); Module._free(ptr); });
Notes:
- Exact exported function names depend on how you compile the decoder. You may need to adapt names and how pointers are returned.
- Free memory when done to avoid leaks.
6) Rendering and scaling
Use canvas CSS and internal width/height to control zoom and fit-to-screen. For high-DPI displays, set canvas.width = width * devicePixelRatio and scale the canvas context with ctx.scale(dpr, dpr).
Zoom example:
- Maintain a scale variable (1.0 default).
- On zoom in/out multiply/divide by 1.25 and redraw: set canvas CSS width/height = width*scale, height*scale; keep internal canvas resolution at width*dpr.
Fit-to-screen:
- Compute available area and choose scale = min(availW / width, availH / height).
7) Handling color profiles and alpha
BPG files can include ICC profiles. The reference decoder may convert to RGB using the embedded profile, or expose the profile for client-side processing. For most viewers, rely on the decoder to output sRGB or a correct converted RGB buffer.
Alpha channel:
- If the image includes alpha, keep alpha in RGBA buffer; when rendering to canvas, alpha will be respected. If the decoder outputs premultiplied alpha, ensure you handle it correctly or request non-premultiplied output.
8) Drag-and-drop, keyboard shortcuts, and UX polish
- Add dragover/drop listeners to accept dropped .bpg files.
- Keyboard shortcuts: space to toggle fit/actual size, +/- for zoom, left/right to step images (if implementing a folder view).
- Show a loading spinner while decoding large images.
Accessibility tips:
- Make controls keyboard-focusable and add ARIA labels.
- Announce image dimensions to assistive tech.
9) Security considerations
- Treat any decoded data as untrusted. Running the decoder in WebAssembly sandbox is good, but ensure you use a well-audited decoder.
- If using third-party prebuilt wasm, verify its provenance.
- Do not eval or execute data from files.
10) Optional: Electron desktop app
Wrap the web viewer in Electron for offline desktop use. Basic steps:
- npm init and install electron.
- Create main.js to create a BrowserWindow and load index.html.
- Add native file dialogs via electron.dialog to open .bpg files.
- Bundle wasm and assets. Use electron-builder for packaging.
Example: Complete app.js (compact)
// Assumes bpgdec.js sets up Module and exported functions: // _bpg_decode(ptr, len), _bpg_get_width(), _bpg_get_height(), _bpg_get_rgba_ptr() const fileInput = document.getElementById('fileInput'); const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); let scale = 1; fileInput.addEventListener('change', async (e) => { const f = e.target.files[0]; if (!f) return; const buf = new Uint8Array(await f.arrayBuffer()); const ptr = Module._malloc(buf.length); Module.HEAPU8.set(buf, ptr); const ok = Module._bpg_decode(ptr, buf.length); Module._free(ptr); if (ok !== 0) return alert('Decode failed'); const w = Module._bpg_get_width(), h = Module._bpg_get_height(); const pixPtr = Module._bpg_get_rgba_ptr(); const img = new ImageData(new Uint8ClampedArray(Module.HEAPU8.buffer, pixPtr, w*h*4), w, h); const dpr = window.devicePixelRatio || 1; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); canvas.style.width = (w * scale) + 'px'; canvas.style.height = (h * scale) + 'px'; ctx.setTransform(dpr,0,0,dpr,0,0); ctx.putImageData(img, 0, 0); });
11) Testing and sample images
Find sample .bpg images in BPG-related repositories or generate BPGs from PNG/JPEG using the bpgenc tool:
bpgenc -o sample.bpg sample.png
Test images with different color spaces, alpha, and large dimensions.
12) Next improvements
- Add image caching and thumbnail generation.
- Implement animated BPG (if decoder supports it).
- Support rotation, basic editing (crop/rotate), and exporting to PNG.
- Integrate drag-to-open from file manager in Electron.
Building a simple BPG viewer is mostly about wiring a decoder (WebAssembly) to a canvas and providing a minimal, responsive UI. Start small—load and render a single image—then add features like zoom, metadata display, and packaging once the core decode/render pipeline works.