crossbind
GitHub

libjpeg-turbo for iOS

v3.2.0iOS

libjpeg-turbo 3.2.0 for React Native apps on iOS, precompiled for arm64 devices and simulators as @crossbind/port-jpegturbo-ios.

npm install @crossbind/port-jpegturbo-ios@beta

Install

shell
npm install @crossbind/plugin-react-native@beta @crossbind/plugin-react-native-ios-helper@beta @crossbind/port-jpegturbo-ios@beta
npm install --save-dev @crossbind/plugin-metro@beta
cd ios && pod install
crossbind.config.mjs
import jpegturboIos from '@crossbind/port-jpegturbo-ios/crossbind.config.js';
 
export default {
dependencies: [jpegturboIos],
paths: { config: import.meta.url },
};
metro.config.js
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const CrossbindMetroPlugin = require('@crossbind/plugin-metro');
 
const defaultConfig = getDefaultConfig(__dirname);
 
const config = {
...CrossbindMetroPlugin(defaultConfig),
};
 
module.exports = mergeConfig(defaultConfig, config);

The whole flow, including Expo, is in the React Native playbook.

Usage

The examples the WebAssembly page runs, as iOS compiles them: the same headers and the same calls. They are checked on the WebAssembly build.

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`);
}
PRINTS
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);
}
PRINTS
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`);
PRINTS
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);
PRINTS
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`);
PRINTS
APP0 JFIF 14 B, APP1 Exif 32 B, COM 19 B
orientation 6, comment "made with crossbind", 59 B added

What is different on iOS

  • pod install compiles your headers with the library through the plugin's podspec, and the app build links the result.
  • Named imports from ./native/<header>.h work as on the web: await initNative() once, then call the classes.
  • There is no m.FS and no /memfs: files live in the app's own storage, and your C++ takes their paths.
  • No Worker and no COOP or COEP: runtime: 'mt' uses pthreads directly.

Other platforms

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