crossbind
GitHub

libjpeg-turbo

v3.2.0Imaging

libjpeg-turbo 3.2.0, high-performance JPEG codec, packaged by crossbind as @crossbind/port-jpegturbo and one package per target. Only a variant that is actually on npm beta is listed as published.

npm install @crossbind/port-jpegturbo-wasm@beta
LIVE · 3 APPS · RUNS IN THIS TAB

libjpeg-turbo in your browser: the JPEG controls canvas does not give you

Browsers decode JPEG, and encode it through canvas.toBlob with a single quality number. These apps run the reference JPEG library, compiled by crossbind: they strip a photo's location and camera data without re-encoding it, expose every encoder setting next to this browser's own, and decode a 12 MP photo straight to a thumbnail. The first run downloads 1 MB of WebAssembly once; every app on this page shares it, and nothing is uploaded.

APP 01

See what a photo gives away, then remove it without re-encoding

A phone photo can carry where it was taken, the camera's serial number and a preview image of its own. This reads every segment and shows what they reveal, then writes a copy without them. The image data is copied coefficient by coefficient, not re-encoded, so every pixel of the copy is the same, and the page proves it by decoding both.

The sample is generated in the module with a made-up camera and the coordinates of a public landmark. Your own photo stays in this tab: it is mounted into the module's in-memory filesystem, never uploaded.

Open the sample, or a JPEG from your phone, to see what its metadata says.
SHOW THE CODE
src/native/jpeg_scrub.h
// src/native/jpeg_scrub.h (excerpt): the image is copied, never re-encoded
jvirt_barray_ptr* coefficients = jpeg_read_coefficients(&source.cinfo);
jpeg_copy_critical_parameters(&source.cinfo, &target.cinfo);
target.cinfo.optimize_coding = optimize ? TRUE : FALSE;
if (progressive) jpeg_simple_progression(&target.cinfo);
jpeg_write_coefficients(&target.cinfo, coefficients);
if (keepOrientation && orientation > 1) {
const std::string block = exif::orientationOnly(orientation);
jpeg_write_marker(&target.cinfo, JPEG_APP0 + 1,
reinterpret_cast<const JOCTET*>(block.data()), block.size());
}
// the ICC profile's APP2 segments follow when keepProfile; every
// other APPn and COM segment of the original stays behind
jpeg_finish_compress(&target.cinfo);
main.js
const m = await initNative();
const [path] = await m.autoMountFiles([file], await m.getRandomPath('/memfs')); // the dropped photo
const report = JSON.parse(await m.JpegScrub.inspect(path));
// report.exif.gps: { lat, lon, alt }; report.segments: [{ marker, label, bytes }]
 
await m.FS.mkdirTree('/memfs/out');
const done = JSON.parse(await m.JpegScrub.clean(path, '/memfs/out/clean.jpg', true, true, true, false));
const proof = JSON.parse(await m.JpegScrub.compare(path, '/memfs/out/clean.jpg'));
// the sample: 123,613 B before, 106,361 B after, proof.differing 0
APP 02

The JPEG settings canvas.toBlob does not give you

canvas.toBlob('image/jpeg', quality) takes one number, and each browser maps it to its own encoder. Here the same pixels go through libjpeg-turbo with every setting yours: quality, chroma subsampling, progressive scans, optimised Huffman tables and arithmetic coding. Each result is measured in bytes and PSNR, next to what this browser's toBlob makes at the same quality.

The sample is a 960×640 scene drawn in the module. A photo of yours is decoded straight to at most 1600 pixels a side and stays in this tab. After the first run, every change re-encodes.

Encode the sample, or a photo of yours, to see bytes, PSNR and a zoomed crop for every setting.
SHOW THE CODE
src/native/jpeg_lab.h
// src/native/jpeg_lab.h (excerpt): every setting is a field on the compressor
jpeg_set_defaults(&encoder.cinfo);
jpeg_set_quality(&encoder.cinfo, quality, TRUE);
encoder.cinfo.comp_info[0].h_samp_factor = subsampling == 444 ? 1 : 2;
encoder.cinfo.comp_info[0].v_samp_factor = subsampling == 420 ? 2 : 1;
encoder.cinfo.optimize_coding = optimize ? TRUE : FALSE;
encoder.cinfo.arith_code = arithmetic ? TRUE : FALSE;
if (progressive) jpeg_simple_progression(&encoder.cinfo);
jpeg_start_compress(&encoder.cinfo, TRUE);
main.js
const m = await initNative();
const lab = await new m.JpegLab();
await lab.useSample(960, 640); // or lab.load(path, 1600) for a dropped photo
await m.FS.mkdirTree('/memfs/out');
 
const q75 = JSON.parse(await lab.encode(75, 420, false, false, false, '/memfs/out/a.jpg', ''));
const full = JSON.parse(await lab.encode(75, 444, false, false, false, '/memfs/out/b.jpg', ''));
// q75: 59,481 B at 31.40 dB; full colour detail: 74,930 B at 36.06 dB
APP 03

Decode a 12 MP photo straight to a thumbnail

Set scale_denom before decoding and libjpeg-turbo runs a smaller inverse DCT on every 8×8 block, so a 1/8 thumbnail is decoded at its own size instead of shrunk from a full decode. Every number here is measured in this tab: the same file at 1/1, 1/2, 1/4 and 1/8. Entropy decoding costs the same at every scale, so how much a thumbnail saves depends on the file.

The sample is a 4032×3024 scene, the size a 12 MP phone camera writes, encoded in the module at quality 85. Each scale is decoded three times and the fastest run counts.

Decode the sample, or a photo of yours, to time each scale.
SHOW THE CODE
src/native/jpeg_thumbs.h
// src/native/jpeg_thumbs.h (excerpt): scale before decoding, not after
jpeg_read_header(&decoder.cinfo, TRUE);
decoder.cinfo.scale_num = 1;
decoder.cinfo.scale_denom = denominator; // 1, 2, 4 or 8
decoder.cinfo.out_color_space = JCS_EXT_RGBA;
jpeg_start_decompress(&decoder.cinfo); // output_width: image_width / denominator, rounded up
while (decoder.cinfo.output_scanline < decoder.cinfo.output_height) {
JSAMPROW row = reinterpret_cast<JSAMPROW>(&rgba[decoder.cinfo.output_scanline * stride]);
jpeg_read_scanlines(&decoder.cinfo, &row, 1);
}
main.js
const m = await initNative();
await m.FS.mkdirTree('/memfs/out');
await m.ThumbnailBench.writeSample('/memfs/out/photo.jpg'); // 4032x3024, 1,289,719 B
 
const thumb = JSON.parse(await m.ThumbnailBench.decode('/memfs/out/photo.jpg', 8, 3, '/memfs/out/thumb.rgba'));
// thumb.width 504, thumb.height 378: 762,048 B of RGBA instead of 48,771,072 B
const rgba = await m.getFileBytes('/memfs/out/thumb.rgba');

Usage

The calls most libjpeg-turbo code makes, each a small C++ header crossbind binds and the JavaScript that uses it. Every example runs here in WebAssembly and prints what the site build checked; the same headers and calls work on Android and iOS.

Each example also has a JavaScript only tab: the same task with no C++ file, calling libjpeg-turbo's own headers from @crossbind/port-jpegturbo directly. None of them work that way; each tab says what stops it.

Encode pixels to a JPEG

jpeg_mem_dest writes the file into memory; jpeg_set_quality and the luma sampling factors decide how much detail it keeps.

src/native/jpeg_encoder.h
#pragma once
 
#include <cstdio>
#include <cstdlib>
#include <jpeglib.h>
 
#include <stdexcept>
#include <string>
 
// Pixels in, a JPEG file out, both in memory. Bytes cross the binding as a byte string: one UTF-16
// code unit (0-255) per byte.
class JpegEncoder {
public:
static std::string version() {
const int number = LIBJPEG_TURBO_VERSION_NUMBER;
return std::to_string(number / 1000000) + "." + std::to_string(number / 1000 % 1000) + "." + std::to_string(number % 1000);
}
 
// rgba holds width * height * 4 bytes. subsampling is 444, 422 or 420: the chroma resolution
// kept for each 2x2 block of pixels (all of it, half, a quarter).
static std::u16string encode(const std::u16string& rgba, int width, int height, int quality, int subsampling) {
if (width < 1 || height < 1 || rgba.size() != static_cast<size_t>(width) * height * 4) throw std::invalid_argument("rgba must hold width * height * 4 bytes");
if (quality < 1 || quality > 100) throw std::invalid_argument("quality must be between 1 and 100");
if (subsampling != 444 && subsampling != 422 && subsampling != 420) throw std::invalid_argument("subsampling must be 444, 422 or 420");
std::string pixels(rgba.size(), '\0');
for (size_t i = 0; i < rgba.size(); ++i) {
if (rgba[i] > 0xFF) throw std::invalid_argument("not a byte string");
pixels[i] = static_cast<char>(rgba[i]);
}
 
Compressor jpeg;
unsigned char* buffer = nullptr;
unsigned long size = 0;
jpeg_mem_dest(&jpeg.cinfo, &buffer, &size);
jpeg.cinfo.image_width = static_cast<JDIMENSION>(width);
jpeg.cinfo.image_height = static_cast<JDIMENSION>(height);
jpeg.cinfo.input_components = 4;
jpeg.cinfo.in_color_space = JCS_EXT_RGBA;
jpeg_set_defaults(&jpeg.cinfo);
jpeg_set_quality(&jpeg.cinfo, quality, TRUE);
jpeg.cinfo.comp_info[0].h_samp_factor = subsampling == 444 ? 1 : 2;
jpeg.cinfo.comp_info[0].v_samp_factor = subsampling == 420 ? 2 : 1;
jpeg_start_compress(&jpeg.cinfo, TRUE);
while (jpeg.cinfo.next_scanline < jpeg.cinfo.image_height) {
JSAMPROW row = reinterpret_cast<JSAMPROW>(&pixels[static_cast<size_t>(jpeg.cinfo.next_scanline) * width * 4]);
jpeg_write_scanlines(&jpeg.cinfo, &row, 1);
}
// jpeg_mem_dest reallocates as the file grows and hands the final buffer over only here.
jpeg_finish_compress(&jpeg.cinfo);
std::u16string file(buffer, buffer + size);
std::free(buffer);
return file;
}
 
private:
// libjpeg's default error handler ends the process; this one throws libjpeg's message instead.
[[noreturn]] static void throwError(j_common_ptr cinfo) {
char message[JMSG_LENGTH_MAX];
(*cinfo->err->format_message)(cinfo, message);
throw std::runtime_error(message);
}
 
struct Compressor {
jpeg_compress_struct cinfo;
jpeg_error_mgr jerr;
Compressor() {
cinfo.err = jpeg_std_error(&jerr);
jerr.error_exit = throwError;
jpeg_create_compress(&cinfo);
}
~Compressor() { jpeg_destroy_compress(&cinfo); }
Compressor(const Compressor&) = delete;
Compressor& operator=(const Compressor&) = delete;
};
};
main.js
import { initNative, JpegEncoder } from './native/jpeg_encoder.h';
 
await initNative();
const [width, height] = [100, 75];
const rgba = new Uint8Array(width * height * 4);
for (let i = 0; i < width * height; i += 1) {
const [x, y] = [i % width, Math.floor(i / width)];
const disc = (x - 50) ** 2 + (y - 37) ** 2 < 400;
rgba.set(disc ? [230, 30, 40, 255] : [x * 2, y * 3, 160, 255], i * 4);
}
const pixels = String.fromCharCode(...rgba);
console.log(`libjpeg-turbo ${await JpegEncoder.version()}: ${width}x${height}, ${rgba.length} B of RGBA`);
for (const [quality, subsampling] of [[90, 444], [90, 420], [50, 420]]) {
const jpeg = await JpegEncoder.encode(pixels, width, height, quality, subsampling);
console.log(`quality ${quality}, subsampling ${subsampling}: ${jpeg.length} B`);
}
PRINTSfirst run downloads 1 MB
libjpeg-turbo 3.2.0: 100x75, 30000 B of RGBA
quality 90, subsampling 444: 2791 B
quality 90, subsampling 420: 1932 B
quality 50, subsampling 420: 1171 B

Decode a JPEG and read its header

jpeg_mem_src reads the file from memory: jpeg_read_header answers what the image is before a pixel is decoded, and jpeg_read_scanlines decodes it to RGBA. A file libjpeg-turbo cannot read throws its message.

src/native/jpeg_decoder.h
#pragma once
 
#include <cstdio>
#include <jpeglib.h>
 
#include <stdexcept>
#include <string>
 
// A JPEG file in memory to RGBA pixels, and what its header says. A file libjpeg-turbo cannot read
// throws libjpeg's own message instead of ending the program.
class JpegDecoder {
public:
// {"width","height","components","colorSpace","progressive"}, read without decoding a pixel.
static std::string header(const std::u16string& jpeg) {
const std::string file = toBytes(jpeg);
Decompressor decoder;
jpeg_mem_src(&decoder.cinfo, reinterpret_cast<const unsigned char*>(file.data()), file.size());
jpeg_read_header(&decoder.cinfo, TRUE);
const jpeg_decompress_struct& info = decoder.cinfo;
return "{\"width\":" + std::to_string(info.image_width) + ",\"height\":" + std::to_string(info.image_height) +
",\"components\":" + std::to_string(info.num_components) + ",\"colorSpace\":\"" + colorSpace(info.jpeg_color_space) +
"\",\"progressive\":" + (jpeg_has_multiple_scans(&decoder.cinfo) ? "true" : "false") + "}";
}
 
// width * height * 4 bytes of RGBA; greyscale files come out with R = G = B.
static std::u16string decode(const std::u16string& jpeg) {
const std::string file = toBytes(jpeg);
Decompressor decoder;
jpeg_mem_src(&decoder.cinfo, reinterpret_cast<const unsigned char*>(file.data()), file.size());
jpeg_read_header(&decoder.cinfo, TRUE);
decoder.cinfo.out_color_space = JCS_EXT_RGBA;
jpeg_start_decompress(&decoder.cinfo);
const size_t stride = static_cast<size_t>(decoder.cinfo.output_width) * 4;
std::u16string rgba(stride * decoder.cinfo.output_height, u'\0');
std::string row(stride, '\0');
while (decoder.cinfo.output_scanline < decoder.cinfo.output_height) {
const size_t y = decoder.cinfo.output_scanline;
JSAMPROW pointer = reinterpret_cast<JSAMPROW>(&row[0]);
jpeg_read_scanlines(&decoder.cinfo, &pointer, 1);
for (size_t x = 0; x < stride; ++x) rgba[y * stride + x] = static_cast<unsigned char>(row[x]);
}
jpeg_finish_decompress(&decoder.cinfo);
return rgba;
}
 
private:
static std::string toBytes(const std::u16string& units) {
std::string bytes(units.size(), '\0');
for (size_t i = 0; i < units.size(); ++i) {
if (units[i] > 0xFF) throw std::invalid_argument("not a byte string");
bytes[i] = static_cast<char>(units[i]);
}
return bytes;
}
 
static const char* colorSpace(J_COLOR_SPACE space) {
switch (space) {
case JCS_GRAYSCALE: return "greyscale";
case JCS_RGB: return "RGB";
case JCS_YCbCr: return "YCbCr";
case JCS_CMYK: return "CMYK";
case JCS_YCCK: return "YCCK";
default: return "unknown";
}
}
 
// libjpeg's default error handler ends the process; this one throws libjpeg's message instead.
[[noreturn]] static void throwError(j_common_ptr cinfo) {
char message[JMSG_LENGTH_MAX];
(*cinfo->err->format_message)(cinfo, message);
throw std::runtime_error(message);
}
 
struct Decompressor {
jpeg_decompress_struct cinfo;
jpeg_error_mgr jerr;
Decompressor() {
cinfo.err = jpeg_std_error(&jerr);
jerr.error_exit = throwError;
jpeg_create_decompress(&cinfo);
}
~Decompressor() { jpeg_destroy_decompress(&cinfo); }
Decompressor(const Decompressor&) = delete;
Decompressor& operator=(const Decompressor&) = delete;
};
};
main.js
import { initNative, JpegDecoder } from './native/jpeg_decoder.h';
import { JpegEncoder } from './native/jpeg_encoder.h';
 
await initNative();
const [width, height] = [100, 75];
const rgba = new Uint8Array(width * height * 4);
for (let i = 0; i < width * height; i += 1) {
const [x, y] = [i % width, Math.floor(i / width)];
const disc = (x - 50) ** 2 + (y - 37) ** 2 < 400;
rgba.set(disc ? [230, 30, 40, 255] : [x * 2, y * 3, 160, 255], i * 4);
}
const jpeg = await JpegEncoder.encode(String.fromCharCode(...rgba), width, height, 90, 420); // the first example's encoder
 
const info = JSON.parse(await JpegDecoder.header(jpeg));
console.log(`${info.width}x${info.height}, ${info.components} components, ${info.colorSpace}, ${info.progressive ? 'progressive' : 'baseline'}`);
const decoded = Uint8Array.from(await JpegDecoder.decode(jpeg), (c) => c.charCodeAt(0));
const at = (10 * width + 10) * 4;
console.log(`${decoded.length} B of RGBA; pixel (10, 10) was ${rgba.slice(at, at + 4)} and decodes as ${decoded.slice(at, at + 4)}`);
try {
await JpegDecoder.decode('crossbind');
} catch (error) {
console.log(error.message);
}
PRINTSfirst run downloads 1 MB
100x75, 3 components, YCbCr, baseline
30000 B of RGBA; pixel (10, 10) was 20,30,160,255 and decodes as 22,29,159,255
std::runtime_error: Not a JPEG file: starts with 0x63 0x72

Decode straight to a thumbnail

Set scale_num and scale_denom before decoding and libjpeg-turbo runs a smaller inverse DCT on every block: a 1/8 decode never builds the full-size image.

src/native/jpeg_thumbnail.h
#pragma once
 
#include <cstdio>
#include <jpeglib.h>
 
#include <stdexcept>
#include <string>
 
// Decodes straight to a smaller image: with scale_num / scale_denom set, libjpeg-turbo runs a
// smaller inverse DCT on every 8x8 block, so a 1/8 thumbnail never exists at full size.
class JpegThumbnail {
public:
// The size a decode at 1/denominator produces, "WxH"; each side rounds up.
static std::string size(const std::u16string& jpeg, int denominator) {
const std::string file = toBytes(jpeg);
Decompressor decoder;
open(decoder, file, denominator);
jpeg_calc_output_dimensions(&decoder.cinfo);
return std::to_string(decoder.cinfo.output_width) + "x" + std::to_string(decoder.cinfo.output_height);
}
 
// RGBA pixels of the decode at 1/denominator (1, 2, 4 or 8).
static std::u16string decode(const std::u16string& jpeg, int denominator) {
const std::string file = toBytes(jpeg);
Decompressor decoder;
open(decoder, file, denominator);
decoder.cinfo.out_color_space = JCS_EXT_RGBA;
jpeg_start_decompress(&decoder.cinfo);
const size_t stride = static_cast<size_t>(decoder.cinfo.output_width) * 4;
std::string rgba(stride * decoder.cinfo.output_height, '\0');
while (decoder.cinfo.output_scanline < decoder.cinfo.output_height) {
JSAMPROW row = reinterpret_cast<JSAMPROW>(&rgba[decoder.cinfo.output_scanline * stride]);
jpeg_read_scanlines(&decoder.cinfo, &row, 1);
}
jpeg_finish_decompress(&decoder.cinfo);
return std::u16string(rgba.begin(), rgba.end());
}
 
private:
// libjpeg's default error handler ends the process; this one throws libjpeg's message instead.
[[noreturn]] static void throwError(j_common_ptr cinfo) {
char message[JMSG_LENGTH_MAX];
(*cinfo->err->format_message)(cinfo, message);
throw std::runtime_error(message);
}
 
struct Decompressor {
jpeg_decompress_struct cinfo;
jpeg_error_mgr jerr;
Decompressor() {
cinfo.err = jpeg_std_error(&jerr);
jerr.error_exit = throwError;
jpeg_create_decompress(&cinfo);
}
~Decompressor() { jpeg_destroy_decompress(&cinfo); }
Decompressor(const Decompressor&) = delete;
Decompressor& operator=(const Decompressor&) = delete;
};
 
static void open(Decompressor& decoder, const std::string& file, int denominator) {
if (denominator != 1 && denominator != 2 && denominator != 4 && denominator != 8) throw std::invalid_argument("denominator must be 1, 2, 4 or 8");
jpeg_mem_src(&decoder.cinfo, reinterpret_cast<const unsigned char*>(file.data()), file.size());
jpeg_read_header(&decoder.cinfo, TRUE);
decoder.cinfo.scale_num = 1;
decoder.cinfo.scale_denom = static_cast<unsigned int>(denominator);
}
 
static std::string toBytes(const std::u16string& units) {
std::string bytes(units.size(), '\0');
for (size_t i = 0; i < units.size(); ++i) {
if (units[i] > 0xFF) throw std::invalid_argument("not a byte string");
bytes[i] = static_cast<char>(units[i]);
}
return bytes;
}
};
main.js
import { initNative, JpegThumbnail } from './native/jpeg_thumbnail.h';
import { JpegEncoder } from './native/jpeg_encoder.h';
 
await initNative();
const [width, height] = [100, 75];
const rgba = new Uint8Array(width * height * 4);
for (let i = 0; i < width * height; i += 1) {
const [x, y] = [i % width, Math.floor(i / width)];
const disc = (x - 50) ** 2 + (y - 37) ** 2 < 400;
rgba.set(disc ? [230, 30, 40, 255] : [x * 2, y * 3, 160, 255], i * 4);
}
const jpeg = await JpegEncoder.encode(String.fromCharCode(...rgba), width, height, 90, 420); // the first example's encoder
 
for (const denominator of [1, 2, 4, 8]) {
console.log(`1/${denominator}: ${await JpegThumbnail.size(jpeg, denominator)}`);
}
const thumbnail = await JpegThumbnail.decode(jpeg, 8);
console.log(`${thumbnail.length} B of RGBA instead of ${rgba.length} B`);
PRINTSfirst run downloads 1 MB
1/1: 100x75
1/2: 50x38
1/4: 25x19
1/8: 13x10
520 B of RGBA instead of 30000 B

Make a JPEG smaller without re-encoding it

jpeg_read_coefficients and jpeg_write_coefficients copy the quantised DCT coefficients from one file to another, as jpegtran -copy all does: the Huffman coding is redone, optimised or progressive, and every pixel stays the same.

src/native/jpeg_transcode.h
#pragma once
 
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <jpeglib.h>
 
#include <stdexcept>
#include <string>
 
// Rewrites a JPEG without decoding it to pixels, as `jpegtran -copy all` does: the quantised DCT
// coefficients are copied as they are, so no quality is lost. Only their entropy coding changes.
class JpegTranscoder {
public:
// optimize: Huffman tables fitted to this image instead of the standard ones.
// progressive: scans that draw a coarse image first; libjpeg-turbo optimises those tables anyway.
static std::u16string transcode(const std::u16string& jpeg, bool optimize, bool progressive) {
const std::string file = toBytes(jpeg);
Decompressor source;
jpeg_mem_src(&source.cinfo, reinterpret_cast<const unsigned char*>(file.data()), file.size());
jpeg_save_markers(&source.cinfo, JPEG_COM, 0xFFFF);
for (int app = 0; app < 16; ++app) jpeg_save_markers(&source.cinfo, JPEG_APP0 + app, 0xFFFF);
jpeg_read_header(&source.cinfo, TRUE);
jvirt_barray_ptr* coefficients = jpeg_read_coefficients(&source.cinfo);
 
Compressor target;
unsigned char* buffer = nullptr;
unsigned long size = 0;
jpeg_mem_dest(&target.cinfo, &buffer, &size);
jpeg_copy_critical_parameters(&source.cinfo, &target.cinfo);
target.cinfo.optimize_coding = optimize ? TRUE : FALSE;
if (progressive) jpeg_simple_progression(&target.cinfo);
jpeg_write_coefficients(&target.cinfo, coefficients);
for (jpeg_saved_marker_ptr marker = source.cinfo.marker_list; marker != nullptr; marker = marker->next) {
// The library writes its own JFIF and Adobe segments; copying the old ones would duplicate them.
if (target.cinfo.write_JFIF_header && marker->marker == JPEG_APP0 && startsWith(marker, "JFIF", 5)) continue;
if (target.cinfo.write_Adobe_marker && marker->marker == JPEG_APP0 + 14 && startsWith(marker, "Adobe", 5)) continue;
jpeg_write_marker(&target.cinfo, marker->marker, marker->data, marker->data_length);
}
// jpeg_mem_dest reallocates as the file grows and hands the final buffer over only here.
jpeg_finish_compress(&target.cinfo);
jpeg_finish_decompress(&source.cinfo);
std::u16string out(buffer, buffer + size);
std::free(buffer);
return out;
}
 
private:
static bool startsWith(jpeg_saved_marker_ptr marker, const char* id, unsigned int length) {
return marker->data_length >= length && std::memcmp(marker->data, id, length) == 0;
}
 
static std::string toBytes(const std::u16string& units) {
std::string bytes(units.size(), '\0');
for (size_t i = 0; i < units.size(); ++i) {
if (units[i] > 0xFF) throw std::invalid_argument("not a byte string");
bytes[i] = static_cast<char>(units[i]);
}
return bytes;
}
 
// libjpeg's default error handler ends the process; this one throws libjpeg's message instead.
[[noreturn]] static void throwError(j_common_ptr cinfo) {
char message[JMSG_LENGTH_MAX];
(*cinfo->err->format_message)(cinfo, message);
throw std::runtime_error(message);
}
 
struct Decompressor {
jpeg_decompress_struct cinfo;
jpeg_error_mgr jerr;
Decompressor() {
cinfo.err = jpeg_std_error(&jerr);
jerr.error_exit = throwError;
jpeg_create_decompress(&cinfo);
}
~Decompressor() { jpeg_destroy_decompress(&cinfo); }
Decompressor(const Decompressor&) = delete;
Decompressor& operator=(const Decompressor&) = delete;
};
 
struct Compressor {
jpeg_compress_struct cinfo;
jpeg_error_mgr jerr;
Compressor() {
cinfo.err = jpeg_std_error(&jerr);
jerr.error_exit = throwError;
jpeg_create_compress(&cinfo);
}
~Compressor() { jpeg_destroy_compress(&cinfo); }
Compressor(const Compressor&) = delete;
Compressor& operator=(const Compressor&) = delete;
};
};
main.js
import { initNative, JpegTranscoder } from './native/jpeg_transcode.h';
import { JpegEncoder } from './native/jpeg_encoder.h';
import { JpegDecoder } from './native/jpeg_decoder.h';
 
await initNative();
const [width, height] = [100, 75];
const rgba = new Uint8Array(width * height * 4);
for (let i = 0; i < width * height; i += 1) {
const [x, y] = [i % width, Math.floor(i / width)];
const disc = (x - 50) ** 2 + (y - 37) ** 2 < 400;
rgba.set(disc ? [230, 30, 40, 255] : [x * 2, y * 3, 160, 255], i * 4);
}
const jpeg = await JpegEncoder.encode(String.fromCharCode(...rgba), width, height, 90, 420); // the first example's encoder
 
const optimized = await JpegTranscoder.transcode(jpeg, true, false);
const progressive = await JpegTranscoder.transcode(jpeg, false, true);
console.log(`baseline ${jpeg.length} B, optimized ${optimized.length} B, progressive ${progressive.length} B`);
const pixels = await JpegDecoder.decode(jpeg); // the second example's decoder
console.log((await JpegDecoder.decode(optimized)) === pixels && (await JpegDecoder.decode(progressive)) === pixels);
PRINTSfirst run downloads 1 MB
baseline 1932 B, optimized 1512 B, progressive 1773 B
true

Read and write EXIF and comments

jpeg_save_markers keeps the APPn and COM segments libjpeg-turbo would otherwise skip, and jpeg_write_marker adds new ones. Here an EXIF orientation and a comment go in without re-encoding the image.

src/native/jpeg_markers.h
#pragma once
 
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <jpeglib.h>
 
#include <stdexcept>
#include <string>
 
// APPn and COM segments carry everything that is not the image: EXIF (APP1), ICC profiles (APP2),
// XMP, comments. libjpeg-turbo skips them unless asked: jpeg_save_markers keeps them for reading,
// jpeg_write_marker adds new ones.
class JpegMarkers {
public:
// Sets the EXIF orientation (1-8) and a comment without re-encoding: the coefficients are copied,
// and every other segment is kept.
static std::u16string tag(const std::u16string& jpeg, int orientation, const std::string& comment) {
if (orientation < 1 || orientation > 8) throw std::invalid_argument("orientation must be between 1 and 8");
const std::string file = toBytes(jpeg);
Decompressor source;
open(source, file);
jvirt_barray_ptr* coefficients = jpeg_read_coefficients(&source.cinfo);
 
Compressor target;
unsigned char* buffer = nullptr;
unsigned long size = 0;
jpeg_mem_dest(&target.cinfo, &buffer, &size);
jpeg_copy_critical_parameters(&source.cinfo, &target.cinfo);
jpeg_write_coefficients(&target.cinfo, coefficients);
const std::string exif = exifWithOrientation(orientation);
jpeg_write_marker(&target.cinfo, JPEG_APP0 + 1, reinterpret_cast<const JOCTET*>(exif.data()), static_cast<unsigned int>(exif.size()));
for (jpeg_saved_marker_ptr marker = source.cinfo.marker_list; marker != nullptr; marker = marker->next) {
if (marker->marker == JPEG_COM || (marker->marker == JPEG_APP0 + 1 && startsWith(marker, "Exif\0", 6))) continue; // replaced
if (target.cinfo.write_JFIF_header && marker->marker == JPEG_APP0 && startsWith(marker, "JFIF", 5)) continue; // written by the library
jpeg_write_marker(&target.cinfo, marker->marker, marker->data, marker->data_length);
}
jpeg_write_marker(&target.cinfo, JPEG_COM, reinterpret_cast<const JOCTET*>(comment.data()), static_cast<unsigned int>(comment.size()));
// jpeg_mem_dest reallocates as the file grows and hands the final buffer over only here.
jpeg_finish_compress(&target.cinfo);
jpeg_finish_decompress(&source.cinfo);
std::u16string out(buffer, buffer + size);
std::free(buffer);
return out;
}
 
// {"markers":[{"marker":"APP1","label":"Exif","bytes":32},...],"orientation":6,"comment":"..."}:
// every APPn and COM segment in file order (a COM has no label), the EXIF orientation (0 when
// there is none) and the first comment.
static std::string read(const std::u16string& jpeg) {
const std::string file = toBytes(jpeg);
Decompressor decoder;
open(decoder, file);
std::string markers;
int orientation = 0;
std::string comment;
bool hasComment = false;
for (jpeg_saved_marker_ptr marker = decoder.cinfo.marker_list; marker != nullptr; marker = marker->next) {
const bool com = marker->marker == JPEG_COM;
if (com && !hasComment) {
comment.assign(reinterpret_cast<const char*>(marker->data), marker->data_length);
hasComment = true;
}
if (marker->marker == JPEG_APP0 + 1 && startsWith(marker, "Exif\0", 6) && orientation == 0) orientation = exifOrientation(marker);
markers += std::string(markers.empty() ? "" : ",") + "{\"marker\":\"" + (com ? std::string("COM") : "APP" + std::to_string(marker->marker - JPEG_APP0)) +
"\",\"label\":\"" + label(marker) + "\",\"bytes\":" + std::to_string(marker->data_length) + "}";
}
return "{\"markers\":[" + markers + "],\"orientation\":" + std::to_string(orientation) + ",\"comment\":" + jsonString(comment) + "}";
}
 
private:
// libjpeg's default error handler ends the process; this one throws libjpeg's message instead.
[[noreturn]] static void throwError(j_common_ptr cinfo) {
char message[JMSG_LENGTH_MAX];
(*cinfo->err->format_message)(cinfo, message);
throw std::runtime_error(message);
}
 
struct Decompressor {
jpeg_decompress_struct cinfo;
jpeg_error_mgr jerr;
Decompressor() {
cinfo.err = jpeg_std_error(&jerr);
jerr.error_exit = throwError;
jpeg_create_decompress(&cinfo);
}
~Decompressor() { jpeg_destroy_decompress(&cinfo); }
Decompressor(const Decompressor&) = delete;
Decompressor& operator=(const Decompressor&) = delete;
};
 
struct Compressor {
jpeg_compress_struct cinfo;
jpeg_error_mgr jerr;
Compressor() {
cinfo.err = jpeg_std_error(&jerr);
jerr.error_exit = throwError;
jpeg_create_compress(&cinfo);
}
~Compressor() { jpeg_destroy_compress(&cinfo); }
Compressor(const Compressor&) = delete;
Compressor& operator=(const Compressor&) = delete;
};
 
// Reads the header and keeps every APPn and COM segment.
static void open(Decompressor& decoder, const std::string& file) {
jpeg_mem_src(&decoder.cinfo, reinterpret_cast<const unsigned char*>(file.data()), file.size());
jpeg_save_markers(&decoder.cinfo, JPEG_COM, 0xFFFF);
for (int app = 0; app < 16; ++app) jpeg_save_markers(&decoder.cinfo, JPEG_APP0 + app, 0xFFFF);
jpeg_read_header(&decoder.cinfo, TRUE);
}
 
static const char* label(jpeg_saved_marker_ptr marker) {
if (marker->marker == JPEG_COM) return "";
if (marker->marker == JPEG_APP0 && startsWith(marker, "JFIF", 5)) return "JFIF";
if (marker->marker == JPEG_APP0 + 1 && startsWith(marker, "Exif\0", 6)) return "Exif";
if (marker->marker == JPEG_APP0 + 1 && startsWith(marker, "http://ns.adobe.com/xap/1.0/", 29)) return "XMP";
if (marker->marker == JPEG_APP0 + 2 && startsWith(marker, "ICC_PROFILE", 12)) return "ICC profile";
if (marker->marker == JPEG_APP0 + 14 && startsWith(marker, "Adobe", 5)) return "Adobe";
return "unknown";
}
 
static bool startsWith(jpeg_saved_marker_ptr marker, const char* id, unsigned int length) {
return marker->data_length >= length && std::memcmp(marker->data, id, length) == 0;
}
 
// "Exif\0\0", then a big-endian TIFF header and one IFD holding tag 0x0112 (Orientation, SHORT).
static std::string exifWithOrientation(int orientation) {
const unsigned char bytes[] = {'E', 'x', 'i', 'f', 0, 0, 'M', 'M', 0, 42, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1,
0, static_cast<unsigned char>(orientation), 0, 0, 0, 0, 0, 0};
return std::string(reinterpret_cast<const char*>(bytes), sizeof(bytes));
}
 
// Reads Orientation from IFD0 of an EXIF segment, in either TIFF byte order; 0 when it is absent.
static int exifOrientation(jpeg_saved_marker_ptr marker) {
const unsigned char* tiff = marker->data + 6;
const size_t length = marker->data_length - 6;
if (length < 8) return 0;
const bool little = tiff[0] == 'I';
const auto read16 = [&](size_t at) { return little ? tiff[at] | tiff[at + 1] << 8 : tiff[at] << 8 | tiff[at + 1]; };
const auto read32 = [&](size_t at) { return static_cast<size_t>(read16(little ? at + 2 : at)) << 16 | static_cast<size_t>(read16(little ? at : at + 2)); };
const size_t ifd = read32(4);
if (ifd + 2 > length) return 0;
const size_t count = static_cast<size_t>(read16(ifd));
for (size_t entry = 0; entry < count && ifd + 2 + entry * 12 + 12 <= length; ++entry) {
const size_t at = ifd + 2 + entry * 12;
if (read16(at) == 0x0112) return read16(at + 8);
}
return 0;
}
 
static std::string jsonString(const std::string& text) {
std::string out = "\"";
for (const unsigned char c : text) {
if (c == '"' || c == '\\') {
out += '\\';
out += static_cast<char>(c);
} else if (c < 0x20) {
char escaped[8];
std::snprintf(escaped, sizeof(escaped), "\\u%04x", c);
out += escaped;
} else {
out += static_cast<char>(c);
}
}
return out + "\"";
}
 
static std::string toBytes(const std::u16string& units) {
std::string bytes(units.size(), '\0');
for (size_t i = 0; i < units.size(); ++i) {
if (units[i] > 0xFF) throw std::invalid_argument("not a byte string");
bytes[i] = static_cast<char>(units[i]);
}
return bytes;
}
};
main.js
import { initNative, JpegMarkers } from './native/jpeg_markers.h';
import { JpegEncoder } from './native/jpeg_encoder.h';
 
await initNative();
const [width, height] = [100, 75];
const rgba = new Uint8Array(width * height * 4);
for (let i = 0; i < width * height; i += 1) {
const [x, y] = [i % width, Math.floor(i / width)];
const disc = (x - 50) ** 2 + (y - 37) ** 2 < 400;
rgba.set(disc ? [230, 30, 40, 255] : [x * 2, y * 3, 160, 255], i * 4);
}
const jpeg = await JpegEncoder.encode(String.fromCharCode(...rgba), width, height, 90, 420); // the first example's encoder
 
const tagged = await JpegMarkers.tag(jpeg, 6, 'made with crossbind');
const info = JSON.parse(await JpegMarkers.read(tagged));
console.log(info.markers.map(({ marker, label, bytes }) => [marker, label, `${bytes} B`].filter(Boolean).join(' ')).join(', '));
console.log(`orientation ${info.orientation}, comment "${info.comment}", ${tagged.length - jpeg.length} B added`);
PRINTSfirst run downloads 1 MB
APP0 JFIF 14 B, APP1 Exif 32 B, COM 19 B
orientation 6, comment "made with crossbind", 59 B added

Add it to your project

One package per platform: install the ones you build for and list each in crossbind.config.js; crossbind compiles only the one that matches the build target. Your C++ goes in src/native, next to the headers it binds. Libraries explains the whole flow.

shell
npm install @crossbind/port-jpegturbo-wasm@beta
crossbind.config.js
import jpegturboWasm from '@crossbind/port-jpegturbo-wasm/crossbind.config.js';
 
export default {
dependencies: [jpegturboWasm],
paths: { config: import.meta.url },
};

Platforms

PlatformRuns inBuildsPage
WebAssemblybrowsers, Node.js and edge runtimeswasm32, single-threaded and multi-threadedlibjpeg-turbo for WebAssembly
AndroidReact Native apps on Androidarm64-v8a devices and the x86_64 emulatorlibjpeg-turbo for Android
iOSReact Native apps on iOSarm64 devices and simulatorslibjpeg-turbo for iOS
macOSnative Node.js addons and Electron on macOSarm64 and x64, macOS 11 or laterlibjpeg-turbo for macOS
Linuxnative Node.js addons on Linuxx64 and arm64, glibc 2.28 or laterlibjpeg-turbo for Linux
Windowsnative Node.js addons on Windowsx64 and arm64, Windows 10 or laterlibjpeg-turbo for Windows
WASIcommand-line programs under wasmtimewasm32-wasip3, single-threadedlibjpeg-turbo for WASI

Packages

TargetPackagenpm `beta`
Meta package@crossbind/port-jpegturbo2.0.0-beta.62
Web and Node.js@crossbind/port-jpegturbo-wasm2.0.0-beta.62
WASI library@crossbind/port-jpegturbo-wasi2.0.0-beta.62
WASI commands@crossbind/port-jpegturbo-standalone-wasinot published
Android@crossbind/port-jpegturbo-android2.0.0-beta.62
iOS@crossbind/port-jpegturbo-ios2.0.0-beta.62
macOS@crossbind/port-jpegturbo-darwin2.0.0-beta.62
Linux@crossbind/port-jpegturbo-linux2.0.0-beta.62
Linux (musl)@crossbind/port-jpegturbo-linuxmuslnot published
Windows@crossbind/port-jpegturbo-win322.0.0-beta.62

Licence

  • npm license field of @crossbind/port-jpegturbo: (IJG AND BSD-3-Clause AND Zlib).
  • Upstream declares IJG AND BSD-3-Clause AND Zlib; the npm field normalises it to SPDX.
  • The licence files that ship with the package, and the port recipe, are in the port directory.

Facts on this page come from the port manifests in the repository and from what npm served on beta when the site was built. See the Libraries guide for the full consumer flow.

MORE LIBRARIES
cURLExpatGDALGEOSGeoTIFFiconvLERClibTIFFOpenSSLPROJSpatiaLiteSQLiteWebPzlibZstandard
Type to search every guide page and section.
↑↓ navigate↵ openesc close