IronOCR on .NET: Large TIFF Files Fail to Load (Files Over 2 GB)
Overview
IronOCR loads image input into a single in-memory buffer through its internal AnyBitmap type. That buffer is indexed by a 32-bit integer, so it caps at roughly 2 GB no matter how much system memory is available. A TIFF file larger than 2 GB exceeds this limit and fails to load. This is reported as fixed as of IronOCR [2026.8.1] - upgrading is the simplest fix if you can confirm that resolves it for your case. If you're on an earlier version and can't upgrade yet, split the TIFF into sub-2 GB chunks before handing it to IronOCR; this article covers two ways to do that.
- Language/Runtime: .NET (cross-platform — the limit is a runtime constraint, not OS-specific)
- Version Found: 2026.5.2
- Version Resolved: 2026.8.1
Cause
OcrInput.LoadImage(filePath) creates an AnyBitmap internally and materializes the whole image into one buffer. Because that buffer is indexed by int, it is effectively limited to about 2 GB regardless of available system memory. A TIFF slightly over that limit cannot be held, so the load fails. Note that LoadImage(filePath) currently returns zero loaded pages instead of throwing a clear exception — this silent failure is a known issue and a clearer exception is planned. The underlying single-buffer design is an architectural limit; native per-page TIFF streaming, which would avoid buffering the whole file, was not available prior to 2026.8.1.
Solution
Recommended: upgrade to IronOCR 2026.8.1 or later, where this limitation is resolved.
If you cannot upgrade yet, split the TIFF into sub-2 GB chunks before handing it to IronOCR, and load each chunk with OcrInput.LoadImage(byte[]) — not the file path — so nothing larger than the limit ever reaches AnyBitmap. Two approaches are documented below.
Option A: Magick.NET (simplest setup)
1. Add the Magick.NET package
Add Magick.NET so you can split the TIFF before handing it to IronOCR. Pick the variant that matches your project (Q8/Q16, AnyCPU/x64).
dotnet add package Magick.NET-Q16-AnyCPU
2. Split the TIFF and OCR each chunk.
Open the TIFF as a MagickImageCollection, split it into page-count chunks that each stay under 2 GB, convert each chunk to a byte array, and load it with OcrInput.LoadImage(byte[]).
var inputPath = "input.tiff";
var pagesPerChunk = 100;
using var allPages = new MagickImageCollection(inputPath);
int chunkNumber = 1;
for (int i = 0; i < allPages.Count; i += pagesPerChunk)
{
using var chunk = new MagickImageCollection();
for (int j = i; j < Math.Min(i + pagesPerChunk, allPages.Count); j++)
{
chunk.Add(allPages[j].Clone());
}
foreach (var image in chunk)
{
image.SetCompression(CompressionMethod.LZW);
}
var chunkBytes = chunk.ToByteArray();
using (var ocrInput = new OcrInput())
{
ocrInput.LoadImage(chunkBytes);
var pages = ocrInput.GetPages().ToList();
Console.WriteLine($"Loaded pages: {pages.Count}");
var result = new IronTesseract().Read(ocrInput);
Console.WriteLine("OCR Text Length: " + (result.Text?.Length ?? 0));
}
Console.WriteLine($"Chunk {chunkNumber} processed");
chunkNumber++;
}
Tune pagesPerChunk for your data. Lower it if a chunk approaches 2 GB or if memory is tight; raise it for smaller pages to reduce overhead.
Account for Magick.NET in your deployment. It ships ImageMagick native binaries, which add to package size and bring a native dependency footprint.
Option B: LibTiff.NET (fully managed, no native dependency)
A fully managed alternative that copies pages at the raw level, with no decode or re-encode step. Useful when a native dependency footprint or exact pixel/compression fidelity matters.
Prerequisites:
- IronOCR (IronTesseract) in a .NET project
- BitMiracle.LibTiff.NET (install command below)
- The LibTiff types used here live in the
BitMiracle.LibTiff.Classicnamespace
1. Add the LibTiff.NET package.
dotnet add package BitMiracle.LibTiff.NET
2. Add the TiffPageSplitter helper below. It copies each page at the raw strip or tile level, so pixel data and compression are preserved exactly with no decode or re-encode. It streams pages one at a time, so peak memory is roughly one chunk rather than the whole file. It yields multi-page chunks that each stay under the size cap.
/// <summary>
/// Splits a multi-page TIFF into single-page TIFF byte streams without ever
/// holding the whole file in memory. Each page is copied at the raw
/// (still-encoded) strip/tile level, so pixel data and compression are
/// preserved exactly — there is no decode/re-encode step.
///
/// This is the chunking step only. It produces sub-2 GB single-page byte
/// arrays; feeding them to IronOCR (which is where the AnyBitmap 2 GB
/// single-buffer limit lives) is the consumer's job — see TiffOcrExample.
/// </summary>
public static class TiffPageSplitter
{
// Tags that describe how a page's raw strip/tile data is encoded.
// With a raw copy nothing is re-encoded, so every one of these must be
// carried over verbatim or the copied bytes become uninterpretable.
// Extend this list if your TIFFs carry tags not covered here
// (e.g. ICC profiles, EXTRASAMPLES for alpha channels).
private static readonly TiffTag[] ScalarIntTags =
{
TiffTag.IMAGEWIDTH,
TiffTag.IMAGELENGTH,
TiffTag.BITSPERSAMPLE,
TiffTag.SAMPLESPERPIXEL,
TiffTag.COMPRESSION,
TiffTag.PHOTOMETRIC,
TiffTag.FILLORDER,
TiffTag.PLANARCONFIG,
TiffTag.ORIENTATION,
TiffTag.RESOLUTIONUNIT,
TiffTag.PREDICTOR, // required for LZW / Deflate raw copies
TiffTag.SAMPLEFORMAT,
TiffTag.T4OPTIONS, // CCITT Group 3
TiffTag.T6OPTIONS, // CCITT Group 4
TiffTag.SUBFILETYPE,
};
private static readonly TiffTag[] ScalarDoubleTags =
{
TiffTag.XRESOLUTION, // DPI directly affects OCR accuracy
TiffTag.YRESOLUTION,
};
/// <summary>
/// Lazily yields each page of <paramref name="inputPath"/> as a standalone
/// single-page TIFF. The source file stays open for the lifetime of the
/// enumeration and only one page is materialised at a time, so peak memory
/// is roughly one page rather than the whole file.
/// </summary>
public static IEnumerable<byte[]> SplitTiffToPages(string inputPath)
{
using (Tiff input = Tiff.Open(inputPath, "r"))
{
if (input == null)
throw new InvalidOperationException($"Could not open TIFF: {inputPath}");
int pageCount = input.NumberOfDirectories();
for (int page = 0; page < pageCount; page++)
{
input.SetDirectory((short)page);
yield return ExtractCurrentPage(input);
}
}
}
private static byte[] ExtractCurrentPage(Tiff input)
{
using (var ms = new MemoryStream())
{
// Default TiffStream operates on the MemoryStream passed as clientData.
using (Tiff output = Tiff.ClientOpen("InMemory", "w", ms, new TiffStream()))
{
if (output == null)
throw new InvalidOperationException("Could not create in-memory TIFF.");
CopyTags(input, output);
if (input.IsTiled())
CopyRawTiles(input, output);
else
CopyRawStrips(input, output);
output.WriteDirectory();
}
return ms.ToArray();
}
} private static void CopyTags(Tiff input, Tiff output)
{ foreach (TiffTag tag in ScalarIntTags)
{
FieldValue[] v = input.GetField(tag);
if (v != null && v.Length > 0)
output.SetField(tag, v[0].ToInt());
}
foreach (TiffTag tag in ScalarDoubleTags)
{
FieldValue[] v = input.GetField(tag);
if (v != null && v.Length > 0)
output.SetField(tag, v[0].ToDouble());
}
// Strip vs tile layout must match the raw data exactly, otherwise the
// raw bytes won't line up with the declared boundaries.
if (input.IsTiled())
{
output.SetField(TiffTag.TILEWIDTH, input.GetField(TiffTag.TILEWIDTH)[0].ToInt());
output.SetField(TiffTag.TILELENGTH, input.GetField(TiffTag.TILELENGTH)[0].ToInt());
}
else
{
FieldValue[] rps = input.GetField(TiffTag.ROWSPERSTRIP);
if (rps != null && rps.Length > 0)
output.SetField(TiffTag.ROWSPERSTRIP, rps[0].ToInt());
}
// Palette images: the colour map is required to interpret pixel indices.
FieldValue[] cmap = input.GetField(TiffTag.COLORMAP);
if (cmap != null && cmap.Length >= 3)
output.SetField(TiffTag.COLORMAP,
cmap[0].ToShortArray(), cmap[1].ToShortArray(), cmap[2].ToShortArray());
}
private static void CopyRawStrips(Tiff input, Tiff output)
{
int stripCount = input.NumberOfStrips();
int[] byteCounts = input.GetField(TiffTag.STRIPBYTECOUNTS)[0].ToIntArray();
for (int strip = 0; strip < stripCount; strip++)
{
byte[] buffer = new byte[byteCounts[strip]];
int read = input.ReadRawStrip(strip, buffer, 0, buffer.Length);
output.WriteRawStrip(strip, buffer, read);
}
}
private static void CopyRawTiles(Tiff input, Tiff output)
{
int tileCount = input.NumberOfTiles();
int[] byteCounts = input.GetField(TiffTag.TILEBYTECOUNTS)[0].ToIntArray();
for (int tile = 0; tile < tileCount; tile++)
{
byte[] buffer = new byte[byteCounts[tile]];
int read = input.ReadRawTile(tile, buffer, 0, buffer.Length);
output.WriteRawTile(tile, buffer, read);
}
}
/// <summary>
/// Lazily yields multi-page TIFF chunks (the equivalent of the old
/// Magick.NET 100-pages-per-chunk approach). A new chunk is started when
/// adding the next page would push the chunk past
/// <paramref name="maxChunkBytes"/>, or when <paramref name="maxPagesPerChunk"/>
/// is reached — whichever comes first.
///
/// Size is the real guard: page count alone can exceed the 2 GB AnyBitmap
/// limit on large pages. The byte total here is the encoded (compressed)
/// size, which is a cheap proxy — validate the cap against your actual
/// pages, since decoded size can be much larger than encoded.
/// </summary>
public static IEnumerable<byte[]> SplitTiffToChunks(
string inputPath,
int maxPagesPerChunk = 100,
long maxChunkBytes = 1_500_000_000L)
{
using (Tiff input = Tiff.Open(inputPath, "r"))
{
if (input == null)
throw new InvalidOperationException($"Could not open TIFF: {inputPath}");
int pageCount = input.NumberOfDirectories();
int page = 0;
while (page < pageCount)
{
using (var ms = new MemoryStream())
{
using (Tiff output = Tiff.ClientOpen("InMemory", "w", ms, new TiffStream()))
{
if (output == null)
throw new InvalidOperationException("Could not create in-memory TIFF.");
int pagesInChunk = 0;
long chunkBytes = 0;
while (page < pageCount && pagesInChunk < maxPagesPerChunk)
{
input.SetDirectory((short)page);
long pageBytes = RawPageByteSize(input);
// Stop before exceeding the cap, but always allow at
// least one page so a single large page still goes through.
if (pagesInChunk > 0 && chunkBytes + pageBytes > maxChunkBytes)
break;
CopyTags(input, output);
if (input.IsTiled())
CopyRawTiles(input, output);
else
CopyRawStrips(input, output);
output.WriteDirectory();
// finalise this page as one directory in the chunk
chunkBytes += pageBytes;
pagesInChunk++;
page++;
}
}
yield return ms.ToArray();
}
}
}
}
private static long RawPageByteSize(Tiff page)
{
TiffTag tag = page.IsTiled() ? TiffTag.TILEBYTECOUNTS : TiffTag.STRIPBYTECOUNTS;
int[] counts = page.GetField(tag)[0].ToIntArray();
long total = 0;
foreach (int c in counts)
total += c;
return total;
}
}
3. OCR each chunk. Iterate SplitTiffToChunks and load each byte array with OcrInput.LoadImage(byte[]) — not the file path — so nothing larger than the limit ever reaches AnyBitmap.
var inputPath = "2gb_benchmark1200.tiff";
var ocr = new IronTesseract();
int chunk = 0;
foreach (byte[] chunkBytes in TiffPageSplitter.SplitTiffToChunks(inputPath, maxPagesPerChunk: 100))
{
using (var ocrInput = new OcrInput())
{
ocrInput.LoadImage(chunkBytes);
// loads every page in the chunk
var result = ocr.Read(ocrInput);
Console.WriteLine($"Chunk {chunk}: {result.Text?.Length ?? 0} chars");
}
chunk++;
}
Tune maxPagesPerChunk and maxChunkBytes for your data. Lower them if a chunk approaches 2 GB once decoded or if memory is tight. Raise the page count for smaller pages to reduce overhead.
Choosing between Option A and Option B
- Option A (Magick.NET) is quicker to set up and needs no custom helper code, but ships ImageMagick native binaries (adds to package size and deployment footprint) and decodes/re-encodes each page.
- Option B (LibTiff.NET) is fully managed with no native dependency, copies pages at the raw strip/tile level with no decode/re-encode step (preserving original compression and pixel data exactly), and streams one page at a time for lower peak memory. It takes more setup code and only carries over the TIFF tags the helper explicitly lists.
Notes and Limitations
- The 2 GB cap is an architectural limit of IronOCR's
AnyBitmap(a single buffer indexed by a 32-bit integer), not an OS or available-memory issue. It was not fixed by native per-page TIFF streaming until 2026.8.1. OcrInput.LoadImage(filePath)currently returns zero loaded pages instead of throwing a clear error when the file is too large, on versions prior to the fix. This silent failure is a known issue and a clearer exception is planned. Chunking sidesteps it by keeping every input under the limit.- The LibTiff.NET splitter only carries over the TIFF tags listed in
ScalarIntTagsandScalarDoubleTags. If your TIFFs use tags not covered there — for example ICC profiles orEXTRASAMPLESfor alpha channels — extend those lists, or the raw-copied bytes may be misinterpreted. maxChunkBytesis measured against the encoded (compressed) size, which is only a cheap proxy. Decoded size can be much larger, so validate the cap against your actual pages. The default is 1.5 GB to leave headroom under the 2 GB limit.- A single page larger than the cap is always allowed through on its own, so one very large page can still exceed 2 GB once decoded.
References