crossbind
GitHub

libTIFF

v4.7.2Imaging

libTIFF 4.7.2, tIFF image reader and writer, packaged by crossbind as @crossbind/port-tiff and one package per target. Only a variant that is actually on npm beta is listed as published.

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

TIFF in any browser, from fax pages to float elevation models

MDN lists Safari as the only browser that shows TIFF images by itself. These apps run libtiff, compiled by crossbind: they open multi-page, 16-bit and float TIFFs with their tags, turn page photos into one fax-grade archive, and compare every codec on scientific rasters. The first run downloads 2.4 MB of WebAssembly once; every app on this page shares it, and nothing is uploaded.

APP 01

Open any TIFF, in any browser

Scans, faxes, 16-bit microscope images, float elevation models and camera TIFFs, every page with its tags. MDN lists Safari as the only browser that shows TIFF images by itself (image file type guide); here libtiff decodes the file in the page, and greyscale data too deep for a screen is stretched to its own range.

The sample is a four-page TIFF the module writes itself: a CCITT Group 4 letter, a JPEG photo, 12-bit cells in 16-bit ZSTD tiles and a float elevation model with no-data. Your own file stays in this tab: it is mounted into the module's in-memory filesystem, never uploaded.

Open the sample, or a TIFF of your own, to see its pages and tags.
SHOW THE CODE
src/native/tiff_viewer.h
// src/native/tiff_viewer.h (excerpt): any page to RGBA, a band of rows at a time
TIFFRGBAImage image;
if (!TIFFRGBAImageBegin(&image, tif, 0, reason)) throw std::runtime_error(reason);
image.req_orientation = image.orientation; // rows as stored; the page applies Orientation
for (uint32_t top = 0; top < layout.height; top += band) {
const uint32_t rows = std::min(band, layout.height - top);
image.row_offset = static_cast<int>(top);
image.col_offset = 0;
if (!TIFFRGBAImageGet(&image, raster.data(), layout.width, rows)) { /* throw libtiff's error */ }
// each row is averaged into the smaller output as it arrives
}
TIFFRGBAImageEnd(&image);
main.js
const m = await initNative();
const [path] = await m.autoMountFiles([file], await m.getRandomPath('/memfs'));
const { pages } = JSON.parse(await m.TiffViewer.pages(path));
 
await m.FS.mkdirTree('/memfs/viewer');
const view = JSON.parse(await m.TiffViewer.render(path, 0, 1400, true, '/memfs/viewer/page.rgba'));
const rgba = await m.getFileBytes('/memfs/viewer/page.rgba');
const pixels = new Uint8ClampedArray(rgba.buffer, rgba.byteOffset, rgba.byteLength);
canvas.getContext('2d').putImageData(new ImageData(pixels, view.width, view.height), 0, 0);
APP 02

Turn page photos into one archival TIFF

Photograph a few pages, or use the two sample photos, and get one multi-page TIFF: each page is turned black and white with Otsu's threshold, then stored with CCITT Group 4, the coding of Group 4 fax. The two sample A4 pages take 19.6 KB in all, and nothing leaves this tab.

Your photos are decoded by this browser, scaled to at most 2,480 pixels on the longer side and never uploaded. Flat, evenly lit pages work best: there is no perspective correction.

Archive the sample pages, or your own photos, to see each page in black and white and what each codec makes of it.
SHOW THE CODE
src/native/scan_archive.h
// src/native/scan_archive.h (excerpt)
const int cut = threshold < 0 ? otsu(histogram, pixels) : std::min(threshold, 255);
// ... pixels at or below the cut become 1-bit black ...
tiffapps::Closer file(tiffapps::openFile(tifPath, index == 0 ? "w" : "a")); // "a" appends a page
TIFF* tif = file.get();
pageTags(tif, width, height, COMPRESSION_CCITTFAX4); // 1-bit, min-is-white, one strip
TIFFSetField(tif, TIFFTAG_SUBFILETYPE, FILETYPE_PAGE);
TIFFSetField(tif, TIFFTAG_PAGENUMBER, index, count);
TIFFSetField(tif, TIFFTAG_XRESOLUTION, static_cast<float>(dpi));
TIFFSetField(tif, TIFFTAG_YRESOLUTION, static_cast<float>(dpi));
TIFFSetField(tif, TIFFTAG_RESOLUTIONUNIT, RESUNIT_INCH);
writeRows(tif, bits, rowBytes, height);
main.js
const m = await initNative();
const dir = await m.getRandomPath('/memfs'); // a fresh directory for each run
// one page: the RGBA of a canvas the photo was drawn on
await m.FS.writeFile(`${dir}/page.rgba`, new Uint8Array(imageData.data.buffer));
const cut = JSON.parse(await m.ScanArchive.binarize(`${dir}/page.rgba`, width, height, -1, `${dir}/page.bits`));
await m.ScanArchive.addPage(`${dir}/archive.tif`, `${dir}/page.bits`, width, height, 200, 0, 1);
const tiff = await m.getFileBytes(`${dir}/archive.tif`);
APP 03

Compare every codec on 16-bit and float rasters

There is no best TIFF codec for scientific rasters, only a best one for each dataset. The module writes one raster with every codec in this build, reads each file back and compares every sample: here the floating-point predictor makes the elevation model 40% smaller with Deflate, while the horizontal predictor makes the noisy cells bigger.

Both rasters are generated in the module, so the sizes are the same on every machine. These are this data's sizes; other data will rank the codecs differently, and the same calls measure it. LERC can keep every sample, or trade a bounded error for size.

Pick a raster and compress it with every codec, to see sizes and which ones keep every sample.
SHOW THE CODE
src/native/compression_lab.h
// src/native/compression_lab.h (excerpt)
TIFFSetField(tif, TIFFTAG_BITSPERSAMPLE, kind == 0 ? 16 : 32);
TIFFSetField(tif, TIFFTAG_SAMPLEFORMAT, kind == 0 ? SAMPLEFORMAT_UINT : SAMPLEFORMAT_IEEEFP);
TIFFSetField(tif, TIFFTAG_COMPRESSION, compression);
if (predictor != PREDICTOR_NONE) TIFFSetField(tif, TIFFTAG_PREDICTOR, predictor);
if (compression == COMPRESSION_LERC) TIFFSetField(tif, TIFFTAG_LERC_MAXZERROR, maxZError);
// ... 256 x 256 tiles written, then read back with TIFFReadTile and compared sample by sample
main.js
const m = await initNative();
await m.FS.mkdirTree('/memfs/lab');
const lab = await new m.CompressionLab(1, 512); // 512 x 512 float32 elevation
// Deflate (8) with the floating-point predictor (3)
const run = JSON.parse(await lab.run(8, 3, 0, '/memfs/lab/deflate.tif'));
// run.bytes: 287494, run.maxError: 0 (every sample came back exactly)

Usage

The calls most libTIFF 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 libTIFF's own headers from @crossbind/port-tiff directly. None of them work that way; each tab says what stops it.

Encode pixels as a TIFF and decode them back

The core of libtiff: TIFFSetField and TIFFWriteScanline write a page, TIFFGetField and TIFFReadRGBAImageOriented read it back as display pixels. TIFFStreamOpen keeps the file in memory.

src/native/tiff_codec.h
#pragma once
 
#include <tiffio.h>
#include <tiffio.hxx>
 
#include <cstdint>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
 
// Canvas pixels to a TIFF and back, in memory: TIFFStreamOpen gives libtiff a std::ostream or
// std::istream instead of a file. Bytes cross the binding as std::u16string, one code unit (0-255)
// per byte.
class Tiff {
public:
static std::string version() { return TIFFLIB_VERSION_STR_MAJ_MIN_MIC; }
 
// RGBA pixels, as ImageData holds them, to an 8-bit RGB TIFF (alpha is not stored). `compression`
// is a COMPRESSION_* code from tiff.h: 1 none, 5 LZW, 8 Deflate, 32773 PackBits, 50000 ZSTD.
static std::u16string encode(const std::u16string& rgba, int width, int height, int compression) {
if (width <= 0 || height <= 0 || rgba.size() != static_cast<size_t>(width) * height * 4) throw std::invalid_argument("rgba must hold width x height x 4 bytes");
if (!TIFFIsCODECConfigured(static_cast<uint16_t>(compression))) throw std::invalid_argument("compression " + std::to_string(compression) + " is not in this build");
std::vector<unsigned char> rgb(static_cast<size_t>(width) * height * 3);
for (size_t pixel = 0; pixel < rgb.size() / 3; ++pixel) {
for (size_t channel = 0; channel < 3; ++channel) {
const char16_t unit = rgba[pixel * 4 + channel];
if (unit > 0xFF) throw std::invalid_argument("not a byte string: a code unit is above 255");
rgb[pixel * 3 + channel] = static_cast<unsigned char>(unit);
}
}
std::ostringstream out;
TIFF* tif = TIFFStreamOpen("memory", &out);
if (!tif) throw std::runtime_error("libtiff could not open a stream for writing");
TIFFSetField(tif, TIFFTAG_IMAGEWIDTH, width);
TIFFSetField(tif, TIFFTAG_IMAGELENGTH, height);
TIFFSetField(tif, TIFFTAG_SAMPLESPERPIXEL, 3);
TIFFSetField(tif, TIFFTAG_BITSPERSAMPLE, 8);
TIFFSetField(tif, TIFFTAG_PHOTOMETRIC, PHOTOMETRIC_RGB);
TIFFSetField(tif, TIFFTAG_PLANARCONFIG, PLANARCONFIG_CONTIG);
TIFFSetField(tif, TIFFTAG_COMPRESSION, compression);
TIFFSetField(tif, TIFFTAG_ROWSPERSTRIP, TIFFDefaultStripSize(tif, 0));
for (int y = 0; y < height; ++y) {
if (TIFFWriteScanline(tif, &rgb[static_cast<size_t>(y) * width * 3], static_cast<uint32_t>(y), 0) < 0) {
TIFFClose(tif);
throw std::runtime_error("libtiff could not write row " + std::to_string(y));
}
}
TIFFClose(tif);
const std::string bytes = out.str();
std::u16string units(bytes.size(), u'\0');
for (size_t i = 0; i < bytes.size(); ++i) units[i] = static_cast<unsigned char>(bytes[i]);
return units;
}
 
// The first page's size, samples and codec, and the page count.
static std::string describe(const std::u16string& tiff) {
std::istringstream in(fromUnits(tiff));
TIFF* tif = open(in);
uint32_t width = 0;
uint32_t height = 0;
uint16_t samples = 0;
uint16_t bits = 0;
uint16_t compression = 0;
TIFFGetField(tif, TIFFTAG_IMAGEWIDTH, &width);
TIFFGetField(tif, TIFFTAG_IMAGELENGTH, &height);
TIFFGetFieldDefaulted(tif, TIFFTAG_SAMPLESPERPIXEL, &samples);
TIFFGetFieldDefaulted(tif, TIFFTAG_BITSPERSAMPLE, &bits);
TIFFGetFieldDefaulted(tif, TIFFTAG_COMPRESSION, &compression);
const unsigned pages = TIFFNumberOfDirectories(tif);
TIFFClose(tif);
const TIFFCodec* codec = TIFFFindCODEC(compression);
return std::to_string(width) + "x" + std::to_string(height) + ", " + std::to_string(samples) + " x " + std::to_string(bits) + "-bit samples, " +
(codec ? codec->name : "compression " + std::to_string(compression)) + ", " + std::to_string(pages) + (pages == 1 ? " page" : " pages");
}
 
// The first page as RGBA, top row first, whatever its bit depth, colour model, layout or codec.
static std::u16string decode(const std::u16string& tiff) {
std::istringstream in(fromUnits(tiff));
TIFF* tif = open(in);
uint32_t width = 0;
uint32_t height = 0;
TIFFGetField(tif, TIFFTAG_IMAGEWIDTH, &width);
TIFFGetField(tif, TIFFTAG_IMAGELENGTH, &height);
if (static_cast<uint64_t>(width) * height > (64u << 20)) {
TIFFClose(tif);
throw std::runtime_error("more than 64 megapixels: decode it from a file instead");
}
std::vector<uint32_t> raster(static_cast<size_t>(width) * height);
const int ok = TIFFReadRGBAImageOriented(tif, width, height, raster.data(), ORIENTATION_TOPLEFT, 0);
TIFFClose(tif);
if (!ok) throw std::runtime_error("libtiff cannot convert this page to RGBA");
std::u16string rgba(raster.size() * 4, u'\0');
for (size_t i = 0; i < raster.size(); ++i) {
rgba[i * 4] = static_cast<char16_t>(TIFFGetR(raster[i]));
rgba[i * 4 + 1] = static_cast<char16_t>(TIFFGetG(raster[i]));
rgba[i * 4 + 2] = static_cast<char16_t>(TIFFGetB(raster[i]));
rgba[i * 4 + 3] = static_cast<char16_t>(TIFFGetA(raster[i]));
}
return rgba;
}
 
private:
static TIFF* open(std::istringstream& in) {
TIFF* tif = TIFFStreamOpen("memory", &in);
if (!tif) throw std::runtime_error("not a TIFF libtiff can read");
return tif;
}
 
static std::string fromUnits(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: a code unit is above 255");
bytes[i] = static_cast<char>(units[i]);
}
return bytes;
}
};
main.js
import { initNative, Tiff } from './native/tiff_codec.h';
 
await initNative();
const width = 160;
const height = 120;
let rgba = ''; // one character per byte, in the order ImageData uses
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) rgba += String.fromCharCode(x, 64 + (y >> 5) * 32, 255 - x, 255);
}
const tiff = await Tiff.encode(rgba, width, height, 5); // 5 = COMPRESSION_LZW
console.log(`libtiff ${await Tiff.version()}: ${rgba.length} B of RGBA -> ${tiff.length} B, starts with ${tiff.slice(0, 3)}`);
console.log(await Tiff.describe(tiff));
console.log((await Tiff.decode(tiff)) === rgba);
PRINTSfirst run downloads 2.4 MB
libtiff 4.7.2: 76800 B of RGBA -> 27692 B, starts with II*
160x120, 3 x 8-bit samples, LZW, 1 page
true

Store several pages in one file

Scanners and fax software keep a document as one TIFF: every page is a directory closed with TIFFWriteDirectory, listed with TIFFReadDirectory and opened again with TIFFSetDirectory. These pages are 1-bit CCITT Group 4, the fax codec.

src/native/tiff_pages.h
#pragma once
 
#include <tiffio.h>
 
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <stdexcept>
#include <string>
#include <vector>
 
// A multi-page TIFF, the way scanners and fax software store documents: every page is its own
// directory, closed with TIFFWriteDirectory and found again with TIFFSetDirectory. The pages are
// black and white, stored as 1-bit CCITT Group 4. Bytes cross the binding as std::u16string, one
// code unit (0-255) per byte.
//
// The file lives in memory through TIFFClientOpen. TIFFStreamOpen's std::ostream will not do here:
// adding a page reads the previous one back, and libtiff cannot read from an ostream.
class TiffPages {
public:
TiffPages() : tif(openMemory(file, "w")) {}
 
~TiffPages() {
if (tif) TIFFClose(tif);
}
 
// Appends a page from 8-bit grey pixels, one byte each: below 128 is black.
void add(const std::u16string& grey, int width, int height, const std::string& name) {
if (!tif) throw std::logic_error("the document is already finished");
if (width <= 0 || height <= 0 || grey.size() != static_cast<size_t>(width) * height) throw std::invalid_argument("grey must hold width x height bytes");
TIFFSetField(tif, TIFFTAG_SUBFILETYPE, FILETYPE_PAGE);
TIFFSetField(tif, TIFFTAG_IMAGEWIDTH, width);
TIFFSetField(tif, TIFFTAG_IMAGELENGTH, height);
TIFFSetField(tif, TIFFTAG_BITSPERSAMPLE, 1);
TIFFSetField(tif, TIFFTAG_SAMPLESPERPIXEL, 1);
TIFFSetField(tif, TIFFTAG_PHOTOMETRIC, PHOTOMETRIC_MINISWHITE);
TIFFSetField(tif, TIFFTAG_COMPRESSION, COMPRESSION_CCITTFAX4);
TIFFSetField(tif, TIFFTAG_ROWSPERSTRIP, height);
TIFFSetField(tif, TIFFTAG_PAGENUMBER, pages, 0); // 0: the page count is not known yet
TIFFSetField(tif, TIFFTAG_PAGENAME, name.c_str());
std::vector<unsigned char> row((static_cast<size_t>(width) + 7) / 8);
for (int y = 0; y < height; ++y) {
std::fill(row.begin(), row.end(), 0);
for (int x = 0; x < width; ++x) {
if (grey[static_cast<size_t>(y) * width + x] < 128) row[x / 8] |= static_cast<unsigned char>(0x80 >> (x % 8));
}
if (TIFFWriteScanline(tif, row.data(), static_cast<uint32_t>(y), 0) < 0) throw std::runtime_error("libtiff could not write row " + std::to_string(y));
}
if (!TIFFWriteDirectory(tif)) throw std::runtime_error("libtiff could not finish page " + std::to_string(pages + 1));
pages += 1;
}
 
// Closes the file and returns its bytes.
std::u16string finish() {
if (!tif) throw std::logic_error("the document is already finished");
TIFFClose(tif);
tif = nullptr;
std::u16string units(file.bytes.size(), u'\0');
for (size_t i = 0; i < file.bytes.size(); ++i) units[i] = static_cast<unsigned char>(file.bytes[i]);
return units;
}
 
// One line per page: its number, name, size and codec.
static std::string list(const std::u16string& tiff) {
MemoryFile source(tiff);
TIFF* in = openMemory(source, "r");
std::string lines;
do {
uint32_t width = 0;
uint32_t height = 0;
uint16_t compression = 0;
char* name = nullptr;
TIFFGetField(in, TIFFTAG_IMAGEWIDTH, &width);
TIFFGetField(in, TIFFTAG_IMAGELENGTH, &height);
TIFFGetFieldDefaulted(in, TIFFTAG_COMPRESSION, &compression);
const TIFFCodec* codec = TIFFFindCODEC(compression);
lines += "page " + std::to_string(TIFFCurrentDirectory(in) + 1) + " \"" + (TIFFGetField(in, TIFFTAG_PAGENAME, &name) ? name : "") + "\": " +
std::to_string(width) + "x" + std::to_string(height) + ", " + (codec ? codec->name : "?") + "\n";
} while (TIFFReadDirectory(in));
TIFFClose(in);
return lines;
}
 
// Page `index` (from 0) back as 8-bit grey, black 0 and white 255, read with TIFFReadScanline.
static std::u16string grey(const std::u16string& tiff, int index) {
MemoryFile source(tiff);
TIFF* in = openMemory(source, "r");
uint16_t bits = 0;
if (index < 0 || !TIFFSetDirectory(in, static_cast<tdir_t>(index)) || !TIFFGetField(in, TIFFTAG_BITSPERSAMPLE, &bits) || bits != 1) {
TIFFClose(in);
throw std::out_of_range("there is no black-and-white page " + std::to_string(index));
}
uint32_t width = 0;
uint32_t height = 0;
TIFFGetField(in, TIFFTAG_IMAGEWIDTH, &width);
TIFFGetField(in, TIFFTAG_IMAGELENGTH, &height);
std::vector<unsigned char> row(static_cast<size_t>(TIFFScanlineSize(in)));
std::u16string pixels(static_cast<size_t>(width) * height, u'\0');
for (uint32_t y = 0; y < height; ++y) {
if (TIFFReadScanline(in, row.data(), y, 0) < 0) {
TIFFClose(in);
throw std::runtime_error("libtiff could not read row " + std::to_string(y));
}
for (uint32_t x = 0; x < width; ++x) pixels[static_cast<size_t>(y) * width + x] = (row[x / 8] & (0x80 >> (x % 8))) ? 0 : 255;
}
TIFFClose(in);
return pixels;
}
 
private:
// A growable file in memory, and the read, write and seek procs libtiff calls on it.
struct MemoryFile {
std::string bytes;
uint64_t position = 0;
 
MemoryFile() = default;
explicit MemoryFile(const std::u16string& units) : 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: a code unit is above 255");
bytes[i] = static_cast<char>(units[i]);
}
}
 
static tmsize_t read(thandle_t handle, void* buffer, tmsize_t size) {
MemoryFile* self = static_cast<MemoryFile*>(handle);
const uint64_t left = self->position < self->bytes.size() ? self->bytes.size() - self->position : 0;
const size_t count = static_cast<size_t>(std::min<uint64_t>(left, static_cast<uint64_t>(size)));
std::memcpy(buffer, self->bytes.data() + self->position, count);
self->position += count;
return static_cast<tmsize_t>(count);
}
static tmsize_t write(thandle_t handle, void* buffer, tmsize_t size) {
MemoryFile* self = static_cast<MemoryFile*>(handle);
if (self->bytes.size() < self->position + size) self->bytes.resize(static_cast<size_t>(self->position + size));
std::memcpy(&self->bytes[static_cast<size_t>(self->position)], buffer, static_cast<size_t>(size));
self->position += size;
return size;
}
static toff_t seek(thandle_t handle, toff_t offset, int whence) {
MemoryFile* self = static_cast<MemoryFile*>(handle);
const uint64_t base = whence == SEEK_CUR ? self->position : whence == SEEK_END ? self->bytes.size() : 0;
self->position = base + offset;
return self->position;
}
static toff_t size(thandle_t handle) { return static_cast<MemoryFile*>(handle)->bytes.size(); }
static int close(thandle_t) { return 0; }
static int map(thandle_t, void**, toff_t*) { return 0; }
static void unmap(thandle_t, void*, toff_t) {}
};
 
static TIFF* openMemory(MemoryFile& memory, const char* mode) {
TIFF* opened = TIFFClientOpen("memory", mode, &memory, MemoryFile::read, MemoryFile::write, MemoryFile::seek, MemoryFile::close, MemoryFile::size,
MemoryFile::map, MemoryFile::unmap);
if (!opened) throw std::runtime_error(*mode == 'w' ? "libtiff could not open a file in memory" : "not a TIFF libtiff can read");
return opened;
}
 
MemoryFile file;
TIFF* tif = nullptr;
int pages = 0;
};
main.js
import { initNative, TiffPages } from './native/tiff_pages.h';
 
await initNative();
const width = 240;
const height = 320;
const names = ['cover', 'summary', 'appendix'];
const pages = names.map((name, index) => {
let grey = ''; // one byte per pixel: a checkerboard whose squares widen page by page
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) grey += String.fromCharCode(((x >> (index + 2)) + (y >> 4)) % 2 ? 0 : 255);
}
return grey;
});
const scan = await new TiffPages();
for (const [index, name] of names.entries()) await scan.add(pages[index], width, height, name);
const tiff = await scan.finish();
 
console.log(`${names.length} pages in ${tiff.length} B`);
for (const line of (await TiffPages.list(tiff)).trimEnd().split('\n')) console.log(line);
console.log((await TiffPages.grey(tiff, 2)) === pages[2]);
PRINTSfirst run downloads 2.4 MB
3 pages in 6133 B
page 1 "cover": 240x320, CCITT Group 4
page 2 "summary": 240x320, CCITT Group 4
page 3 "appendix": 240x320, CCITT Group 4
true

Keep 32-bit float samples exact

Elevation, temperature and microscopy data are not 8-bit pictures. SAMPLEFORMAT_IEEEFP stores the real values, the floating-point predictor helps them compress and TIFFReadScanline reads them back; TIFFRGBAImageOK says why the RGBA reader cannot.

src/native/tiff_samples.h
#pragma once
 
#include <tiffio.h>
#include <tiffio.hxx>
 
#include <cstdint>
#include <cstring>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
 
// Scientific rasters keep their real values: one band of 32-bit floats, written and read a scanline
// at a time. The samples cross the binding as little-endian float32 bytes in a std::u16string, one
// code unit (0-255) per byte, which is what a Float32Array's buffer holds.
class TiffSamples {
public:
// `compression` is a COMPRESSION_* code; `predictor` 1 is none, 3 is the floating-point predictor,
// which Deflate, LZW and ZSTD compress better.
static std::u16string writeFloat32(const std::u16string& samples, int width, int height, int compression, int predictor) {
if (width <= 0 || height <= 0 || samples.size() != static_cast<size_t>(width) * height * 4) throw std::invalid_argument("samples must hold width x height x 4 bytes");
const std::string raw = fromUnits(samples);
std::ostringstream out;
TIFF* tif = TIFFStreamOpen("memory", &out);
if (!tif) throw std::runtime_error("libtiff could not open a stream for writing");
TIFFSetField(tif, TIFFTAG_IMAGEWIDTH, width);
TIFFSetField(tif, TIFFTAG_IMAGELENGTH, height);
TIFFSetField(tif, TIFFTAG_SAMPLESPERPIXEL, 1);
TIFFSetField(tif, TIFFTAG_BITSPERSAMPLE, 32);
TIFFSetField(tif, TIFFTAG_SAMPLEFORMAT, SAMPLEFORMAT_IEEEFP);
TIFFSetField(tif, TIFFTAG_PHOTOMETRIC, PHOTOMETRIC_MINISBLACK);
TIFFSetField(tif, TIFFTAG_COMPRESSION, compression);
if (predictor != PREDICTOR_NONE) TIFFSetField(tif, TIFFTAG_PREDICTOR, predictor);
TIFFSetField(tif, TIFFTAG_ROWSPERSTRIP, TIFFDefaultStripSize(tif, 0));
std::vector<float> row(static_cast<size_t>(width));
for (int y = 0; y < height; ++y) {
std::memcpy(row.data(), raw.data() + static_cast<size_t>(y) * width * 4, row.size() * 4);
if (TIFFWriteScanline(tif, row.data(), static_cast<uint32_t>(y), 0) < 0) {
TIFFClose(tif);
throw std::runtime_error("libtiff could not write row " + std::to_string(y));
}
}
TIFFClose(tif);
return toUnits(out.str());
}
 
// The samples back, row by row with TIFFReadScanline, as little-endian float32 bytes.
static std::u16string readFloat32(const std::u16string& tiff) {
std::istringstream in(fromUnits(tiff));
TIFF* tif = open(in);
uint32_t width = 0;
uint32_t height = 0;
uint16_t bits = 0;
uint16_t format = 0;
uint16_t samples = 0;
TIFFGetField(tif, TIFFTAG_IMAGEWIDTH, &width);
TIFFGetField(tif, TIFFTAG_IMAGELENGTH, &height);
TIFFGetFieldDefaulted(tif, TIFFTAG_BITSPERSAMPLE, &bits);
TIFFGetFieldDefaulted(tif, TIFFTAG_SAMPLEFORMAT, &format);
TIFFGetFieldDefaulted(tif, TIFFTAG_SAMPLESPERPIXEL, &samples);
if (bits != 32 || format != SAMPLEFORMAT_IEEEFP || samples != 1 || TIFFIsTiled(tif)) {
TIFFClose(tif);
throw std::runtime_error("not a one-band float32 TIFF in strips");
}
std::string raw(static_cast<size_t>(width) * height * 4, '\0');
for (uint32_t y = 0; y < height; ++y) {
if (TIFFReadScanline(tif, &raw[static_cast<size_t>(y) * width * 4], y, 0) < 0) {
TIFFClose(tif);
throw std::runtime_error("libtiff could not read row " + std::to_string(y));
}
}
TIFFClose(tif);
return toUnits(raw);
}
 
// Whether TIFFReadRGBAImage could turn the image into display pixels, and if not, why not.
static std::string rgbaCheck(const std::u16string& tiff) {
std::istringstream in(fromUnits(tiff));
TIFF* tif = open(in);
char reason[1024] = "";
const bool ok = TIFFRGBAImageOK(tif, reason);
TIFFClose(tif);
return ok ? "TIFFReadRGBAImage can convert it" : reason;
}
 
private:
static TIFF* open(std::istringstream& in) {
TIFF* tif = TIFFStreamOpen("memory", &in);
if (!tif) throw std::runtime_error("not a TIFF libtiff can read");
return tif;
}
 
static std::u16string toUnits(const std::string& bytes) {
std::u16string units(bytes.size(), u'\0');
for (size_t i = 0; i < bytes.size(); ++i) units[i] = static_cast<unsigned char>(bytes[i]);
return units;
}
 
static std::string fromUnits(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: a code unit is above 255");
bytes[i] = static_cast<char>(units[i]);
}
return bytes;
}
};
main.js
import { initNative, TiffSamples } from './native/tiff_samples.h';
 
await initNative();
const width = 256;
const height = 256;
// An elevation grid in metres: a smooth saddle in steps of 1/64 m, so float32 holds every value exactly.
const heights = new Float32Array(width * height);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) heights[y * width + x] = 500 + ((x - 128) * (y - 96)) / 64;
}
let samples = ''; // the Float32Array's bytes, one character each
for (const byte of new Uint8Array(heights.buffer)) samples += String.fromCharCode(byte);
 
const plain = await TiffSamples.writeFloat32(samples, width, height, 8, 1); // 8 = Deflate, no predictor
const predicted = await TiffSamples.writeFloat32(samples, width, height, 8, 3); // floating-point predictor
console.log(`${samples.length} B of float32 -> Deflate ${plain.length} B, with predictor 3 ${predicted.length} B`);
console.log((await TiffSamples.readFloat32(predicted)) === samples);
console.log(await TiffSamples.rgbaCheck(predicted));
PRINTSfirst run downloads 2.4 MB
262144 B of float32 -> Deflate 123852 B, with predictor 3 9072 B
true
Sorry, can not handle images with 32-bit samples

Read one tile of a big image

Large TIFFs are cut into tiles so a viewer decodes only what it shows. TIFFWriteTile stores them, TIFFComputeTile finds the one under a pixel and TIFFReadTile decodes it alone.

src/native/tiff_tiles.h
#pragma once
 
#include <tiffio.h>
#include <tiffio.hxx>
 
#include <algorithm>
#include <cstdint>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
 
// Big images are stored in tiles, so a reader decodes only the part it shows: the layout of
// slide scanners, Cloud Optimized GeoTIFF and pyramidal TIFF. Bytes cross the binding as
// std::u16string, one code unit (0-255) per byte.
class TiffTiles {
public:
// RGBA pixels to an 8-bit RGB TIFF in square Deflate tiles; `tileSize` is a multiple of 16.
static std::u16string encode(const std::u16string& rgba, int width, int height, int tileSize) {
if (width <= 0 || height <= 0 || rgba.size() != static_cast<size_t>(width) * height * 4) throw std::invalid_argument("rgba must hold width x height x 4 bytes");
if (tileSize <= 0 || tileSize % 16 != 0) throw std::invalid_argument("tiles are a multiple of 16 pixels wide");
std::ostringstream out;
TIFF* tif = TIFFStreamOpen("memory", &out);
if (!tif) throw std::runtime_error("libtiff could not open a stream for writing");
TIFFSetField(tif, TIFFTAG_IMAGEWIDTH, width);
TIFFSetField(tif, TIFFTAG_IMAGELENGTH, height);
TIFFSetField(tif, TIFFTAG_SAMPLESPERPIXEL, 3);
TIFFSetField(tif, TIFFTAG_BITSPERSAMPLE, 8);
TIFFSetField(tif, TIFFTAG_PHOTOMETRIC, PHOTOMETRIC_RGB);
TIFFSetField(tif, TIFFTAG_PLANARCONFIG, PLANARCONFIG_CONTIG);
TIFFSetField(tif, TIFFTAG_COMPRESSION, COMPRESSION_ADOBE_DEFLATE);
TIFFSetField(tif, TIFFTAG_TILEWIDTH, tileSize);
TIFFSetField(tif, TIFFTAG_TILELENGTH, tileSize);
std::vector<unsigned char> tile(static_cast<size_t>(TIFFTileSize(tif)));
for (int top = 0; top < height; top += tileSize) {
for (int left = 0; left < width; left += tileSize) {
std::fill(tile.begin(), tile.end(), 0); // edge tiles are padded
for (int y = top; y < std::min(top + tileSize, height); ++y) {
for (int x = left; x < std::min(left + tileSize, width); ++x) {
for (int channel = 0; channel < 3; ++channel) {
const char16_t unit = rgba[(static_cast<size_t>(y) * width + x) * 4 + channel];
if (unit > 0xFF) {
TIFFClose(tif);
throw std::invalid_argument("not a byte string: a code unit is above 255");
}
tile[(static_cast<size_t>(y - top) * tileSize + (x - left)) * 3 + channel] = static_cast<unsigned char>(unit);
}
}
}
if (TIFFWriteTile(tif, tile.data(), static_cast<uint32_t>(left), static_cast<uint32_t>(top), 0, 0) < 0) {
TIFFClose(tif);
throw std::runtime_error("libtiff could not write the tile at " + std::to_string(left) + "," + std::to_string(top));
}
}
}
TIFFClose(tif);
const std::string bytes = out.str();
std::u16string units(bytes.size(), u'\0');
for (size_t i = 0; i < bytes.size(); ++i) units[i] = static_cast<unsigned char>(bytes[i]);
return units;
}
 
// "WxH in N tiles of TxT"
static std::string layout(const std::u16string& tiff) {
std::istringstream in(fromUnits(tiff));
TIFF* tif = openTiled(in);
uint32_t width = 0;
uint32_t height = 0;
uint32_t tileWidth = 0;
uint32_t tileHeight = 0;
TIFFGetField(tif, TIFFTAG_IMAGEWIDTH, &width);
TIFFGetField(tif, TIFFTAG_IMAGELENGTH, &height);
TIFFGetField(tif, TIFFTAG_TILEWIDTH, &tileWidth);
TIFFGetField(tif, TIFFTAG_TILELENGTH, &tileHeight);
const uint32_t tiles = TIFFNumberOfTiles(tif);
TIFFClose(tif);
return std::to_string(width) + "x" + std::to_string(height) + " in " + std::to_string(tiles) + " tiles of " + std::to_string(tileWidth) + "x" +
std::to_string(tileHeight);
}
 
// The number of the tile that holds pixel (x, y), counted row by row from 0.
static int tileIndex(const std::u16string& tiff, int x, int y) {
std::istringstream in(fromUnits(tiff));
TIFF* tif = openTiled(in);
const bool inside = x >= 0 && y >= 0 && TIFFCheckTile(tif, static_cast<uint32_t>(x), static_cast<uint32_t>(y), 0, 0);
const uint32_t index = inside ? TIFFComputeTile(tif, static_cast<uint32_t>(x), static_cast<uint32_t>(y), 0, 0) : 0;
TIFFClose(tif);
if (!inside) throw std::out_of_range("the pixel is outside the image");
return static_cast<int>(index);
}
 
// The RGB samples of the tile that holds pixel (x, y), decoded with TIFFReadTile; no other
// tile is read.
static std::u16string tile(const std::u16string& tiff, int x, int y) {
std::istringstream in(fromUnits(tiff));
TIFF* tif = openTiled(in);
if (x < 0 || y < 0 || !TIFFCheckTile(tif, static_cast<uint32_t>(x), static_cast<uint32_t>(y), 0, 0)) {
TIFFClose(tif);
throw std::out_of_range("the pixel is outside the image");
}
std::string samples(static_cast<size_t>(TIFFTileSize(tif)), '\0');
const tmsize_t read = TIFFReadTile(tif, &samples[0], static_cast<uint32_t>(x), static_cast<uint32_t>(y), 0, 0);
TIFFClose(tif);
if (read < 0) throw std::runtime_error("libtiff could not read the tile");
std::u16string units(samples.size(), u'\0');
for (size_t i = 0; i < samples.size(); ++i) units[i] = static_cast<unsigned char>(samples[i]);
return units;
}
 
private:
static TIFF* openTiled(std::istringstream& in) {
TIFF* tif = TIFFStreamOpen("memory", &in);
if (!tif) throw std::runtime_error("not a TIFF libtiff can read");
if (!TIFFIsTiled(tif)) {
TIFFClose(tif);
throw std::runtime_error("this TIFF is stored in strips, not tiles");
}
return tif;
}
 
static std::string fromUnits(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: a code unit is above 255");
bytes[i] = static_cast<char>(units[i]);
}
return bytes;
}
};
main.js
import { initNative, TiffTiles } from './native/tiff_tiles.h';
 
await initNative();
const size = 512;
let rgba = '';
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) rgba += String.fromCharCode(x >> 1, y >> 1, (x + y) >> 2, 255);
}
const tiff = await TiffTiles.encode(rgba, size, size, 128);
console.log(`${await TiffTiles.layout(tiff)}, ${tiff.length} B`);
 
const [x, y] = [300, 200];
const tile = await TiffTiles.tile(tiff, x, y); // RGB, 128 x 128 x 3 bytes
const at = ((y % 128) * 128 + (x % 128)) * 3;
console.log(`pixel (${x}, ${y}) is in tile ${await TiffTiles.tileIndex(tiff, x, y)}, decoded alone: ${tile.length} B`);
console.log(`rgb(${[0, 1, 2].map((channel) => tile.charCodeAt(at + channel)).join(', ')})`);
PRINTSfirst run downloads 2.4 MB
512x512 in 16 tiles of 128x128, 239648 B
pixel (300, 200) is in tile 6, decoded alone: 49152 B
rgb(150, 100, 125)

Open a TIFF file and print its tags

Files reach C++ by path: m.autoMountFiles mounts what an <input type="file"> or a drop gives you, TIFFOpen reads it in place and TIFFPrintDirectory prints every tag, as the tiffinfo tool does.

src/native/tiff_file.h
#pragma once
 
#include <tiffio.h>
 
#include <cstdio>
#include <cstdlib>
#include <stdexcept>
#include <string>
 
// A TIFF by path, which is how files reach C++: mounted from an <input type="file"> in a browser,
// or from the app's storage on a phone. TIFFOpen reads only what each call needs, so the size of
// the file does not matter.
class TiffFile {
public:
static int pages(const std::string& path) {
TIFF* tif = open(path);
const int count = static_cast<int>(TIFFNumberOfDirectories(tif));
TIFFClose(tif);
return count;
}
 
// Page `index` (from 0) as TIFFPrintDirectory prints it, the report the tiffinfo tool shows.
static std::string directory(const std::string& path, int index) {
TIFF* tif = open(path);
if (index < 0 || !TIFFSetDirectory(tif, static_cast<tdir_t>(index))) {
TIFFClose(tif);
throw std::out_of_range("there is no page " + std::to_string(index));
}
char* text = nullptr;
size_t size = 0;
FILE* report = open_memstream(&text, &size);
if (!report) {
TIFFClose(tif);
throw std::runtime_error("could not open a memory stream");
}
TIFFPrintDirectory(tif, report, TIFFPRINT_NONE);
std::fclose(report);
TIFFClose(tif);
const std::string printed(text, size);
std::free(text);
return printed;
}
 
private:
static TIFF* open(const std::string& path) {
TIFF* tif = TIFFOpen(path.c_str(), "r");
if (!tif) throw std::runtime_error("not a TIFF libtiff can read: " + path);
return tif;
}
};
main.js
import { initNative } from './native/tiff_file.h';
 
const m = await initNative();
const { Tiff, TiffFile } = m;
// A File, as an <input type="file"> gives one; this one holds a TIFF made with the first example's class.
let rgba = '';
for (let i = 0; i < 64 * 48; i += 1) rgba += String.fromCharCode((i % 64) * 4, (i >> 6) * 5, 128, 255);
const tiff = await Tiff.encode(rgba, 64, 48, 8); // 8 = Deflate
const file = new File([Uint8Array.from(tiff, (c) => c.charCodeAt(0))], 'photo.tif', { type: 'image/tiff' });
 
const [path] = await m.autoMountFiles([file], await m.getRandomPath('/memfs')); // memory only, gone with the tab
console.log(`${file.name}: ${file.size} B, ${await TiffFile.pages(path)} page`);
for (const line of (await TiffFile.directory(path, 0)).trimEnd().split('\n')) console.log(line);
PRINTSfirst run downloads 2.4 MB
photo.tif: 6260 B, 1 page
TIFF Directory at offset 0x17e0 (6112)
  Image Width: 64 Image Length: 48
  Bits/Sample: 8
  Compression Scheme: AdobeDeflate
  Photometric Interpretation: RGB color
  Samples/Pixel: 3
  Rows/Strip: 42
  Planar Configuration: single image plane

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-tiff-wasm@beta
crossbind.config.js
import tiffWasm from '@crossbind/port-tiff-wasm/crossbind.config.js';
 
export default {
dependencies: [tiffWasm],
paths: { config: import.meta.url },
};

Platforms

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

Packages

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

Licence

  • npm license field of @crossbind/port-tiff: libtiff.
  • 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
cURLExpatGDALGEOSGeoTIFFiconvLERClibjpeg-turboOpenSSLPROJSpatiaLiteSQLiteWebPzlibZstandard
Type to search every guide page and section.
↑↓ navigate↵ openesc close